mixin_bot 1.4.0 → 2.0.0

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +75 -0
  3. data/API_COVERAGE.md +143 -0
  4. data/CHANGELOG.md +112 -0
  5. data/README.md +375 -0
  6. data/docs/agent/cli.md +149 -0
  7. data/docs/agent/cookbook.md +152 -0
  8. data/examples/blaze.rb +43 -0
  9. data/examples/config.yml.example +21 -0
  10. data/lib/mixin_bot/address.rb +43 -3
  11. data/lib/mixin_bot/api/app.rb +7 -0
  12. data/lib/mixin_bot/api/asset.rb +114 -3
  13. data/lib/mixin_bot/api/auth.rb +19 -10
  14. data/lib/mixin_bot/api/blaze.rb +81 -0
  15. data/lib/mixin_bot/api/chain.rb +94 -0
  16. data/lib/mixin_bot/api/code.rb +16 -0
  17. data/lib/mixin_bot/api/computer_api.rb +60 -0
  18. data/lib/mixin_bot/api/conversation.rb +7 -1
  19. data/lib/mixin_bot/api/deposit.rb +12 -0
  20. data/lib/mixin_bot/api/encrypted_message.rb +1 -1
  21. data/lib/mixin_bot/api/fiat.rb +12 -0
  22. data/lib/mixin_bot/api/inscription.rb +2 -2
  23. data/lib/mixin_bot/api/legacy_collectible.rb +26 -27
  24. data/lib/mixin_bot/api/legacy_multisig.rb +20 -21
  25. data/lib/mixin_bot/api/legacy_output.rb +10 -3
  26. data/lib/mixin_bot/api/legacy_payment.rb +2 -0
  27. data/lib/mixin_bot/api/legacy_snapshot.rb +16 -0
  28. data/lib/mixin_bot/api/legacy_transaction.rb +28 -13
  29. data/lib/mixin_bot/api/legacy_transfer.rb +11 -8
  30. data/lib/mixin_bot/api/legacy_user.rb +51 -0
  31. data/lib/mixin_bot/api/me.rb +99 -3
  32. data/lib/mixin_bot/api/message.rb +18 -27
  33. data/lib/mixin_bot/api/multisig.rb +19 -0
  34. data/lib/mixin_bot/api/network.rb +17 -0
  35. data/lib/mixin_bot/api/network_asset.rb +27 -0
  36. data/lib/mixin_bot/api/output.rb +1 -1
  37. data/lib/mixin_bot/api/pin.rb +16 -3
  38. data/lib/mixin_bot/api/pin_payload.rb +26 -0
  39. data/lib/mixin_bot/api/session.rb +14 -0
  40. data/lib/mixin_bot/api/snapshot.rb +6 -0
  41. data/lib/mixin_bot/api/tip.rb +74 -1
  42. data/lib/mixin_bot/api/transaction.rb +106 -17
  43. data/lib/mixin_bot/api/transfer.rb +141 -14
  44. data/lib/mixin_bot/api/turn.rb +12 -0
  45. data/lib/mixin_bot/api/user.rb +148 -45
  46. data/lib/mixin_bot/api/withdraw.rb +24 -23
  47. data/lib/mixin_bot/api.rb +248 -3
  48. data/lib/mixin_bot/bot_auth.rb +71 -0
  49. data/lib/mixin_bot/cli/api.rb +224 -143
  50. data/lib/mixin_bot/cli/base.rb +77 -0
  51. data/lib/mixin_bot/cli/call.rb +71 -0
  52. data/lib/mixin_bot/cli/errors.rb +56 -0
  53. data/lib/mixin_bot/cli/node.rb +9 -2
  54. data/lib/mixin_bot/cli/output.rb +196 -0
  55. data/lib/mixin_bot/cli/schema.rb +274 -0
  56. data/lib/mixin_bot/cli/schema_command.rb +21 -0
  57. data/lib/mixin_bot/cli/utils.rb +114 -18
  58. data/lib/mixin_bot/cli.rb +124 -48
  59. data/lib/mixin_bot/client/error_mapper.rb +40 -0
  60. data/lib/mixin_bot/client.rb +94 -64
  61. data/lib/mixin_bot/computer.rb +132 -0
  62. data/lib/mixin_bot/configuration.rb +108 -1
  63. data/lib/mixin_bot/errors.rb +102 -0
  64. data/lib/mixin_bot/models/address.rb +11 -0
  65. data/lib/mixin_bot/models/api_envelope.rb +67 -0
  66. data/lib/mixin_bot/models/asset.rb +11 -0
  67. data/lib/mixin_bot/models/ghost_keys.rb +14 -0
  68. data/lib/mixin_bot/models/output.rb +11 -0
  69. data/lib/mixin_bot/models/safe_multisig_request.rb +11 -0
  70. data/lib/mixin_bot/models/sequencer_transaction_request.rb +11 -0
  71. data/lib/mixin_bot/models/user.rb +11 -0
  72. data/lib/mixin_bot/models.rb +10 -0
  73. data/lib/mixin_bot/monitor.rb +77 -0
  74. data/lib/mixin_bot/transaction/buffer.rb +34 -0
  75. data/lib/mixin_bot/transaction/decoder.rb +227 -0
  76. data/lib/mixin_bot/transaction/encoder.rb +255 -0
  77. data/lib/mixin_bot/transaction.rb +6 -475
  78. data/lib/mixin_bot/url_scheme.rb +63 -0
  79. data/lib/mixin_bot/utils/address.rb +17 -80
  80. data/lib/mixin_bot/utils/crypto.rb +173 -1
  81. data/lib/mixin_bot/utils/decoder.rb +1 -1
  82. data/lib/mixin_bot/utils/encoder.rb +13 -0
  83. data/lib/mixin_bot/utils.rb +45 -0
  84. data/lib/mixin_bot/uuid.rb +78 -1
  85. data/lib/mixin_bot/version.rb +11 -1
  86. data/lib/mixin_bot.rb +172 -18
  87. data/lib/mvm/bridge.rb +46 -0
  88. data/lib/mvm/client.rb +60 -0
  89. data/lib/mvm/nft.rb +4 -2
  90. data/lib/mvm/registry.rb +2 -1
  91. data/lib/mvm.rb +93 -0
  92. data/lib/tasks/api_coverage.rake +20 -0
  93. data/llms.txt +29 -0
  94. metadata +77 -9
@@ -1,7 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MixinBot
4
+ ##
5
+ # Configuration class for storing Mixin bot credentials and settings.
6
+ #
7
+ # This class handles the configuration of bot credentials including:
8
+ # - Application ID and secret
9
+ # - Session ID and private key
10
+ # - Server public key
11
+ # - Spend key and PIN
12
+ # - API and Blaze host settings
13
+ #
14
+ # == Usage
15
+ #
16
+ # Configure globally:
17
+ #
18
+ # MixinBot.configure do
19
+ # app_id = 'your-app-id'
20
+ # session_id = 'your-session-id'
21
+ # session_private_key = 'your-private-key'
22
+ # server_public_key = 'server-public-key'
23
+ # spend_key = 'your-spend-key'
24
+ # end
25
+ #
26
+ # Or create a specific configuration instance:
27
+ #
28
+ # config = MixinBot::Configuration.new(
29
+ # app_id: 'your-app-id',
30
+ # session_id: 'your-session-id',
31
+ # session_private_key: 'your-private-key',
32
+ # server_public_key: 'server-public-key'
33
+ # )
34
+ #
35
+ # == Key Conversion
36
+ #
37
+ # The configuration automatically handles key format conversions:
38
+ # - Ed25519 keys are converted from seed format (32 bytes) to full format (64 bytes)
39
+ # - Keys are converted to Curve25519 format when needed
40
+ # - Keys can be provided in various encodings (Base64, hex, etc.)
41
+ #
4
42
  class Configuration
43
+ ##
44
+ # List of configurable attributes.
5
45
  CONFIGURABLE_ATTRS = %i[
6
46
  app_id
7
47
  client_secret
@@ -18,6 +58,29 @@ module MixinBot
18
58
  ].freeze
19
59
  attr_accessor(*CONFIGURABLE_ATTRS)
20
60
 
61
+ ##
62
+ # Initializes a new Configuration instance.
63
+ #
64
+ # @param kwargs [Hash] configuration options
65
+ # @option kwargs [String] :app_id the application ID (or :client_id)
66
+ # @option kwargs [String] :client_secret the client secret
67
+ # @option kwargs [String] :session_id the session ID
68
+ # @option kwargs [String] :session_private_key the session private key (or :private_key)
69
+ # @option kwargs [String] :server_public_key the server public key (or :pin_token)
70
+ # @option kwargs [String] :spend_key the spend private key
71
+ # @option kwargs [String] :pin the PIN (defaults to spend_key if not provided)
72
+ # @option kwargs [String] :api_host ('api.mixin.one') the API host
73
+ # @option kwargs [String] :blaze_host ('blaze.mixin.one') the Blaze WebSocket host
74
+ # @option kwargs [Boolean] :debug (false) enable debug logging
75
+ #
76
+ # @example
77
+ # config = MixinBot::Configuration.new(
78
+ # app_id: '25696f85-b7b4-4509-8c3f-2684a8fc4a2a',
79
+ # session_id: '25696f85-b7b4-4509-8c3f-2684a8fc4a2a',
80
+ # session_private_key: 'base64_encoded_key',
81
+ # server_public_key: 'base64_encoded_key'
82
+ # )
83
+ #
21
84
  def initialize(**kwargs)
22
85
  @app_id = kwargs[:app_id] || kwargs[:client_id]
23
86
  @client_secret = kwargs[:client_secret]
@@ -32,12 +95,32 @@ module MixinBot
32
95
  self.pin = kwargs[:pin] || spend_key
33
96
  end
34
97
 
98
+ ##
99
+ # Validates if the configuration has all required credentials.
100
+ #
101
+ # Required fields are:
102
+ # - app_id
103
+ # - session_id
104
+ # - session_private_key
105
+ # - server_public_key
106
+ #
107
+ # @return [Boolean] true if all required fields are present
108
+ #
35
109
  def valid?
36
110
  %i[app_id session_id session_private_key server_public_key].all? do |attr|
37
111
  send(attr).present?
38
112
  end
39
113
  end
40
114
 
115
+ ##
116
+ # Sets the session private key with automatic format conversion.
117
+ #
118
+ # Handles Ed25519 key conversion:
119
+ # - If key is 32 bytes (seed), converts to 64-byte keypair
120
+ # - Automatically converts to Curve25519 format for encryption
121
+ #
122
+ # @param key [String] the session private key in various formats
123
+ #
41
124
  def session_private_key=(key)
42
125
  return if key.blank?
43
126
 
@@ -52,19 +135,35 @@ module MixinBot
52
135
  @session_private_key_curve25519 = JOSE::JWA::Ed25519.sk_to_curve25519(@session_private_key) if @session_private_key.size == 64
53
136
  end
54
137
 
138
+ ##
139
+ # Sets the server public key with automatic format conversion.
140
+ #
141
+ # Converts Ed25519 public key to Curve25519 format when needed.
142
+ # Handles both hex-encoded and Base64-encoded keys.
143
+ #
144
+ # @param key [String] the server public key
145
+ #
55
146
  def server_public_key=(key)
56
147
  return if key.blank?
57
148
 
58
149
  @server_public_key = decode_key key
59
150
  # HEX encoded
60
151
  @server_public_key_curve25519 =
61
- if key.match?(/\A[\h]{32,}\z/i)
152
+ if key.match?(/\A\h{32,}\z/i)
62
153
  JOSE::JWA::Ed25519.pk_to_curve25519 @server_public_key
63
154
  else
64
155
  server_public_key
65
156
  end
66
157
  end
67
158
 
159
+ ##
160
+ # Sets the spend key with automatic format conversion.
161
+ #
162
+ # Used for signing transactions in the Safe API.
163
+ # Converts from seed format to full keypair if needed.
164
+ #
165
+ # @param key [String] the spend private key
166
+ #
68
167
  def spend_key=(key)
69
168
  return if key.blank?
70
169
 
@@ -77,6 +176,14 @@ module MixinBot
77
176
  end
78
177
  end
79
178
 
179
+ ##
180
+ # Sets the PIN with automatic format conversion.
181
+ #
182
+ # The PIN is used for certain operations requiring additional authorization.
183
+ # Defaults to the spend_key if not explicitly set.
184
+ #
185
+ # @param key [String] the PIN key
186
+ #
80
187
  def pin=(key)
81
188
  return if key.blank?
82
189
 
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ ##
5
+ # Base error class for all MixinBot errors.
6
+ #
7
+ class Error < StandardError; end
8
+
9
+ ##
10
+ # Raised when invalid arguments are provided.
11
+ #
12
+ class ArgumentError < StandardError; end
13
+
14
+ ##
15
+ # Raised when HTTP request fails.
16
+ #
17
+ class HttpError < Error; end
18
+
19
+ ##
20
+ # Raised when a request to Mixin API fails.
21
+ #
22
+ class RequestError < Error; end
23
+
24
+ ##
25
+ # Raised when Mixin API returns an error response.
26
+ #
27
+ class ResponseError < Error; end
28
+
29
+ ##
30
+ # Raised when a requested resource is not found (HTTP 404).
31
+ #
32
+ class NotFoundError < Error; end
33
+
34
+ ##
35
+ # Raised when a user is not found (error code 10404).
36
+ #
37
+ class UserNotFoundError < Error; end
38
+
39
+ ##
40
+ # Raised when authentication fails (HTTP 401).
41
+ #
42
+ class UnauthorizedError < Error; end
43
+
44
+ ##
45
+ # Raised when access is forbidden (HTTP 403).
46
+ #
47
+ class ForbiddenError < Error; end
48
+
49
+ ##
50
+ # Raised when there is insufficient balance for a transaction (error code 20117).
51
+ #
52
+ class InsufficientBalanceError < Error; end
53
+
54
+ ##
55
+ # Raised when selected UTXOs cannot cover the requested amount (mirrors Go +UtxoInsufficientError+).
56
+ #
57
+ class UtxoInsufficientError < InsufficientBalanceError
58
+ attr_reader :total_input, :total_output, :output_size
59
+
60
+ def initialize(message, total_input: nil, total_output: nil, output_size: nil)
61
+ super(message)
62
+ @total_input = total_input
63
+ @total_output = total_output
64
+ @output_size = output_size
65
+ end
66
+ end
67
+
68
+ ##
69
+ # Raised when there is insufficient pool for a transaction (error code 30103).
70
+ #
71
+ class InsufficientPoolError < Error; end
72
+
73
+ ##
74
+ # Raised when PIN verification fails (error codes 20118, 20119).
75
+ #
76
+ class PinError < Error; end
77
+
78
+ ##
79
+ # Raised when NFO memo format is invalid.
80
+ #
81
+ class InvalidNfoFormatError < Error; end
82
+
83
+ ##
84
+ # Raised when UUID format is invalid.
85
+ #
86
+ class InvalidUuidFormatError < Error; end
87
+
88
+ ##
89
+ # Raised when transaction format is invalid.
90
+ #
91
+ class InvalidTransactionFormatError < Error; end
92
+
93
+ ##
94
+ # Raised when configuration is not valid or incomplete.
95
+ #
96
+ class ConfigurationNotValidError < Error; end
97
+
98
+ ##
99
+ # Raised when invoice format is invalid.
100
+ #
101
+ class InvalidInvoiceFormatError < Error; end
102
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ module Models
5
+ ##
6
+ # Withdrawal address record (+/addresses+).
7
+ #
8
+ class Address < ApiEnvelope
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ module Models
5
+ ##
6
+ # Wraps a raw Mixin API JSON object so callers can use both:
7
+ # - +response['data']['user_id']+ (envelope shape)
8
+ # - +response['user_id']+ (flattened shape, matching legacy +merge!+ behaviour)
9
+ #
10
+ class ApiEnvelope < SimpleDelegator
11
+ def [](key)
12
+ k = key.is_a?(Symbol) ? key.to_s : key
13
+ inner = __getobj__
14
+ return inner[k] if inner.key?(k)
15
+
16
+ data = inner['data']
17
+ return data[k] if data.is_a?(Hash) && data.key?(k)
18
+
19
+ nil
20
+ end
21
+
22
+ def dig(*keys)
23
+ return nil if keys.empty?
24
+
25
+ k0 = keys[0]
26
+ k0 = k0.to_s if k0.is_a?(Symbol)
27
+ inner = __getobj__
28
+
29
+ if inner.key?(k0)
30
+ v = inner[k0]
31
+ return v if keys.size == 1
32
+
33
+ return v.dig(*keys[1..]) if v.respond_to?(:dig)
34
+ end
35
+
36
+ data = inner['data']
37
+ return nil unless data.is_a?(Hash) && data.key?(k0)
38
+
39
+ v = data[k0]
40
+ return v if keys.size == 1
41
+
42
+ v.respond_to?(:dig) ? v.dig(*keys[1..]) : nil
43
+ end
44
+
45
+ def key?(key)
46
+ k = key.is_a?(Symbol) ? key.to_s : key
47
+ inner = __getobj__
48
+ inner.key?(k) || (inner['data'].is_a?(Hash) && inner['data'].key?(k))
49
+ end
50
+
51
+ alias include? key?
52
+ alias has_key? key?
53
+
54
+ def with_indifferent_access
55
+ inner = __getobj__
56
+ base = inner.dup
57
+ d = base['data']
58
+ merged = d.is_a?(Hash) ? base.merge(d) : base
59
+ merged.with_indifferent_access
60
+ end
61
+
62
+ def to_h
63
+ __getobj__
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ module Models
5
+ ##
6
+ # Wallet asset row (+/assets+, +/assets/:id+).
7
+ #
8
+ class Asset < ApiEnvelope
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ module Models
5
+ ##
6
+ # Ghost key material returned by +/safe/keys+.
7
+ #
8
+ class GhostKeys < ApiEnvelope
9
+ end
10
+
11
+ class GhostKeyRequest < ApiEnvelope
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ module Models
5
+ ##
6
+ # Safe API UTXO / output row (+/safe/outputs+).
7
+ #
8
+ class Output < ApiEnvelope
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ module Models
5
+ ##
6
+ # Safe multisig request (+/safe/multisigs+).
7
+ #
8
+ class SafeMultisigRequest < ApiEnvelope
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ module Models
5
+ ##
6
+ # Sequencer transaction request (+/safe/transaction/requests+, +/safe/transactions+).
7
+ #
8
+ class SequencerTransactionRequest < ApiEnvelope
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ module Models
5
+ ##
6
+ # Typed view of a Mixin +User+ payload (+/me+, +/users/:id+, etc.).
7
+ #
8
+ class User < ApiEnvelope
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'models/api_envelope'
4
+ require_relative 'models/user'
5
+ require_relative 'models/output'
6
+ require_relative 'models/ghost_keys'
7
+ require_relative 'models/sequencer_transaction_request'
8
+ require_relative 'models/safe_multisig_request'
9
+ require_relative 'models/address'
10
+ require_relative 'models/asset'
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module MixinBot
6
+ # Monitoring helpers (parity with Go monitor package).
7
+ module Monitor
8
+ class AppMessage
9
+ attr_accessor :project, :status, :data
10
+
11
+ def initialize(project: nil, status: 0, data: [])
12
+ @project = project
13
+ @status = status
14
+ @data = data
15
+ end
16
+
17
+ def self.load(yaml_str)
18
+ h = YAML.safe_load(yaml_str, permitted_classes: [Symbol], aliases: true)
19
+ new(
20
+ project: h['project'],
21
+ status: h['status'] || 0,
22
+ data: Array(h['data'])
23
+ )
24
+ end
25
+
26
+ def marshal
27
+ YAML.dump(
28
+ 'project' => project,
29
+ 'status' => status,
30
+ 'data' => data
31
+ )
32
+ end
33
+ end
34
+
35
+ class << self
36
+ def unmarshal_app_message(bytes)
37
+ AppMessage.load(bytes)
38
+ end
39
+
40
+ def report_to_monitor(api, asset:, amount:, receivers:, threshold:, message:, trace: nil, **transfer_opts)
41
+ memo = message.is_a?(AppMessage) ? message.marshal : message.to_s
42
+ mix = MixinBot::MixAddress.from_members(members: receivers, threshold:)
43
+ trace ||= MixinBot.utils.unique_object_id(mix.address, asset, amount, api.config.app_id, memo,
44
+ (Time.now.to_i / 60).to_s)
45
+ existing = begin
46
+ api.safe_transaction(trace)
47
+ rescue StandardError
48
+ nil
49
+ end
50
+ return existing if existing.present? && existing['data'].present?
51
+
52
+ api.create_safe_transfer(
53
+ members: receivers,
54
+ threshold:,
55
+ asset_id: asset,
56
+ amount:,
57
+ trace_id: trace,
58
+ memo:,
59
+ **transfer_opts
60
+ )
61
+ end
62
+
63
+ def check_retryable_error(error)
64
+ return false if error.nil?
65
+
66
+ reason = error.message.to_s.downcase
67
+ return true if reason.include?('timeout')
68
+ return true if reason.include?('internal server')
69
+ return true if reason.include?('insufficient')
70
+ return true if reason.include?('inputs locked by')
71
+ return true if reason.include?('by other transaction')
72
+
73
+ false
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ class Transaction
5
+ ##
6
+ # Byte cursor for decoding raw transaction bytes.
7
+ #
8
+ class Buffer
9
+ attr_reader :bytes
10
+
11
+ def initialize(bytes)
12
+ @bytes = bytes
13
+ end
14
+
15
+ def shift(byte_count = nil)
16
+ return @bytes.shift if byte_count.nil?
17
+
18
+ @bytes.shift(byte_count)
19
+ end
20
+
21
+ def peek(byte_count)
22
+ @bytes[0, byte_count]
23
+ end
24
+
25
+ def size
26
+ @bytes.size
27
+ end
28
+
29
+ def empty?
30
+ @bytes.empty?
31
+ end
32
+ end
33
+ end
34
+ end