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,214 +1,295 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MixinBot
4
- class CLI < Thor
5
- desc 'api PATH', 'request PATH of Mixin API'
4
+ class CLI
5
+ desc 'api PATH', 'Signed GET/POST request to a Mixin API path'
6
6
  long_desc <<-LONGDESC
7
- use `mixinbot api PATH` to request any Mixin API
7
+ Use `mixinbot api PATH` to call any Mixin REST endpoint via MixinBot::Client.
8
8
 
9
- Get user infomation:
9
+ Get user information:
10
10
 
11
11
  $ mixinbot api /me -k ~/.mixinbot/keystore.json
12
12
 
13
- Search user infomation:
13
+ Search user:
14
14
 
15
15
  $ mixinbot api /search/1051445 -k ~/.mixinbot/keystore.json
16
16
 
17
- Generate a multisig payment:
17
+ POST with JSON body:
18
18
 
19
- $ mixinbot api /payments -k ~/.mixinbot/keystore.json -m POST -d '{"asset_id":"965e5c6e-434c-3fa9-b780-c50f43cd955c", "amount":"1", "trace_id": "37f16abb-0640-4d01-9423-a06121732d35", "memo":"test", "opponent_multisig":{"receivers":["0508a116-1239-4e28-b150-85a8e3e6b400", "7ed9292d-7c95-4333-aa48-a8c640064186", "a67c6e87-1c9e-4a1c-b81c-47a9f4f1bff1"], "threshold":2}}'
19
+ $ mixinbot api /payments -k keystore.json -m POST -d '{"asset_id":"..."}'
20
20
  LONGDESC
21
21
  option :keystore, type: :string, aliases: '-k', required: true, desc: 'keystore or keystore.json file path'
22
- option :method, type: :string, aliases: '-m', default: 'GET', desc: 'HTTP method, GET or POST'
23
- option :params, type: :hash, aliases: '-p', desc: 'HTTP GET params'
24
- option :data, type: :string, aliases: '-d', default: '{}', desc: 'HTTP POST data'
25
- option :accesstoken, type: :string, aliases: '-t', desc: 'Specify a accesstoken, or will generate by keystore'
22
+ option :method, type: :string, aliases: '-m', default: 'GET', desc: 'HTTP method: GET or POST'
23
+ option :params, type: :hash, aliases: '-p', desc: 'HTTP GET query parameters'
24
+ option :data, type: :string, aliases: '-d', default: '{}', desc: 'HTTP POST JSON body (object or array)'
25
+ option :accesstoken, type: :string, aliases: '-t', desc: 'Override JWT access token'
26
+ option :data_only, type: :boolean, default: true, desc: 'Print only response data (default: true)'
26
27
  def api(path)
27
- path = "#{path}?#{URI.encode_www_form(options[:params])}" if options[:params].present?
28
- payload =
29
- begin
30
- JSON.parse options[:data]
31
- rescue JSON::ParserError => e
32
- log UI.fmt("{{x}} #{e.inspect}")
33
- {}
28
+ with_command_name('api') do
29
+ setup_api_instance!
30
+ verb = options[:method].to_s.upcase
31
+ abort_with_error("unsupported HTTP method #{verb} (use GET or POST)", kind: :unsupported) unless %w[GET POST].include?(verb)
32
+
33
+ path_with_query = path
34
+ path_with_query = "#{path}?#{URI.encode_www_form(options[:params])}" if options[:params].present?
35
+
36
+ payload = parse_api_body(options[:data])
37
+ res = with_spinner("#{verb} #{path_with_query}") do
38
+ case verb
39
+ when 'GET'
40
+ api_instance.client.get(
41
+ path_with_query,
42
+ access_token: options[:accesstoken]
43
+ )
44
+ when 'POST'
45
+ post_via_client(path, payload, access_token: options[:accesstoken])
46
+ end
34
47
  end
35
48
 
36
- access_token = options[:accesstoken] || api_instance.access_token(options[:method].upcase, path, payload.blank? ? '' : payload.to_json)
37
- authorization = format('Bearer %<access_token>s', access_token:)
38
- res = {}
39
-
40
- CLI::UI::Spinner.spin("#{options[:method]} #{path}, payload: #{payload}") do |_spinner|
41
- res =
42
- case options[:method].downcase.to_sym
43
- when :post
44
- api_instance.client.post(path, headers: { Authorization: authorization }, json: payload)
45
- when :get
46
- api_instance.client.get(path, headers: { Authorization: authorization })
47
- end
49
+ print_result(res, data_only: options[:data_only], command: 'api')
48
50
  end
49
-
50
- log res['data']
51
+ rescue MixinBot::Error => e
52
+ abort_with_error(e.message, exception: e)
51
53
  end
52
54
 
53
- desc 'authcode', 'code to authorize other mixin account'
55
+ desc 'authcode', 'OAuth authorization code for another Mixin account'
54
56
  option :keystore, type: :string, aliases: '-k', required: true, desc: 'keystore or keystore.json file path'
55
- option :app_id, type: :string, required: true, aliases: '-c', desc: 'app_id of bot to authorize'
56
- option :scope, type: :array, default: ['PROFILE:READ'], aliases: '-s', desc: 'scope to authorize'
57
+ option :app_id, type: :string, required: true, aliases: '-c', desc: 'client_id of the app to authorize'
58
+ option :scope, type: :array, default: ['PROFILE:READ'], aliases: '-s', desc: 'OAuth scopes'
57
59
  def authcode
58
- res = {}
59
- CLI::UI::Spinner.spin('POST /oauth/authorize') do |_spinner|
60
- res =
60
+ with_command_name('authcode') do
61
+ setup_api_instance!
62
+ res = with_spinner('POST /oauth/authorize') do
61
63
  api_instance.authorize_code(
62
64
  user_id: options[:app_id],
63
65
  scope: options[:scope],
64
66
  pin: keystore['pin']
65
67
  )
68
+ end
69
+ print_result(res, data_only: true, command: 'authcode')
66
70
  end
67
- log res['data']
71
+ rescue MixinBot::Error => e
72
+ abort_with_error(e.message, exception: e)
68
73
  end
69
74
 
70
- desc 'updatetip PIN', 'update TIP pin'
75
+ desc 'updatetip PIN', 'Update TIP PIN'
71
76
  option :keystore, type: :string, aliases: '-k', required: true, desc: 'keystore or keystore.json file path'
72
77
  def updatetip(pin)
73
- profile = api_instance.me
74
- log UI.fmt "{{v}} #{profile['full_name']}, TIP counter: #{profile['tip_counter']}"
78
+ with_command_name('updatetip') do
79
+ setup_api_instance!
80
+ profile = api_instance.me
81
+ emit_info("#{profile['full_name']}, TIP counter: #{profile['tip_counter']}")
75
82
 
76
- counter = profile['tip_counter']
77
- key = api_instance.prepare_tip_key counter
78
- log UI.fmt "{{v}} Generated key: #{key[:private_key]}"
83
+ counter = profile['tip_counter']
84
+ key = api_instance.prepare_tip_key counter
85
+ emit_info("Generated key: #{key[:private_key]}")
79
86
 
80
- res = api_instance.update_pin old_pin: pin.to_s, pin: key[:public_key]
87
+ res = api_instance.update_tip_pin(pin.to_s, key[:public_key])
81
88
 
82
- log({
83
- pin: key[:private_key],
84
- tip_key_base64: res['tip_key_base64']
85
- })
86
- rescue StandardError => e
87
- log UI.fmt "{{x}} #{e.inspect}"
89
+ emit_success(
90
+ {
91
+ 'pin' => key[:private_key],
92
+ 'tip_key_base64' => res['tip_key_base64']
93
+ },
94
+ command: 'updatetip'
95
+ )
96
+ end
97
+ rescue MixinBot::Error => e
98
+ abort_with_error(e.message, exception: e)
88
99
  end
89
100
 
90
- desc 'verifypin PIN', 'verify pin'
101
+ desc 'verifypin PIN', 'Verify PIN'
91
102
  option :keystore, type: :string, aliases: '-k', required: true, desc: 'keystore or keystore.json file path'
92
103
  def verifypin(pin)
93
- res = api_instance.verify_pin pin.to_s
94
-
95
- log res
96
- rescue StandardError => e
97
- log UI.fmt "{{x}} #{e.inspect}"
104
+ with_command_name('verifypin') do
105
+ setup_api_instance!
106
+ res = api_instance.verify_pin pin.to_s
107
+ print_result(res, command: 'verifypin')
108
+ end
109
+ rescue MixinBot::Error => e
110
+ abort_with_error(e.message, exception: e)
98
111
  end
99
112
 
100
- desc 'transfer USER_ID', 'transfer asset to USER_ID'
113
+ desc 'transfer USER_ID', 'Safe transfer to USER_ID'
101
114
  option :asset, type: :string, required: true, desc: 'Asset ID'
102
- option :amount, type: :numeric, required: true, desc: 'Amount'
103
- option :memo, type: :string, required: false, desc: 'memo'
115
+ option :amount, type: :string, required: true, desc: 'Amount'
116
+ option :memo, type: :string, required: false, desc: 'Memo'
117
+ option :trace, type: :string, required: false, desc: 'Trace ID'
118
+ option :spend_key, type: :string, required: false, desc: 'Spend private key (hex); defaults to keystore spend_key'
104
119
  option :keystore, type: :string, aliases: '-k', required: true, desc: 'keystore or keystore.json file path'
120
+ option :dry_run, type: :boolean, default: false, desc: 'Validate and print transfer kwargs without submitting'
105
121
  def transfer(user_id)
106
- res = {}
122
+ with_command_name('transfer') do
123
+ setup_api_instance!
124
+ perform_safe_transfer(user_id)
125
+ end
126
+ end
107
127
 
108
- CLI::UI::Spinner.spin "Try to transfer #{options[:amount]} #{options[:asset]} to #{user_id}" do |_spinner|
109
- res = api_instance.create_transfer(
110
- keystore['pin'],
111
- {
128
+ desc 'legacy-transfer USER_ID', 'Legacy POST /transfers (deprecated)'
129
+ option :asset, type: :string, required: true, desc: 'Asset ID'
130
+ option :amount, type: :numeric, required: true, desc: 'Amount'
131
+ option :memo, type: :string, required: false, desc: 'Memo'
132
+ option :keystore, type: :string, aliases: '-k', required: true, desc: 'keystore or keystore.json file path'
133
+ def legacy_transfer(user_id)
134
+ with_command_name('legacy-transfer') do
135
+ setup_api_instance!
136
+ warn_deprecated('legacy-transfer uses deprecated POST /transfers; use `transfer` (Safe API) instead')
137
+ res = with_spinner("Legacy transfer #{options[:amount]} #{options[:asset]} to #{user_id}") do
138
+ api_instance.create_transfer(
139
+ keystore['pin'],
112
140
  asset_id: options[:asset],
113
141
  opponent_id: user_id,
114
142
  amount: options[:amount],
115
143
  memo: options[:memo]
116
- }
117
- )
118
- end
144
+ )
145
+ end
119
146
 
120
- return unless res['snapshot_id'].present?
147
+ emit_transfer_result(res)
148
+ end
149
+ rescue MixinBot::Error => e
150
+ abort_with_error(e.message, exception: e)
151
+ end
121
152
 
122
- log UI.fmt "{{v}} Finished: https://mixin.one/snapshots/#{res['snapshot_id']}"
153
+ desc 'safetransfer USER_ID', 'Alias for transfer (deprecated name)'
154
+ option :asset, type: :string, required: true, desc: 'Asset ID'
155
+ option :amount, type: :string, required: true, desc: 'Amount'
156
+ option :trace, type: :string, required: false, desc: 'Trace ID'
157
+ option :memo, type: :string, required: false, desc: 'Memo'
158
+ option :spend_key, type: :string, required: false, desc: 'Spend private key (hex)'
159
+ option :keystore, type: :string, aliases: '-k', required: true, desc: 'keystore or keystore.json file path'
160
+ option :dry_run, type: :boolean, default: false, desc: 'Validate and print transfer kwargs without submitting'
161
+ def safetransfer(user_id)
162
+ warn_deprecated('safetransfer is deprecated; use `transfer` instead')
163
+ transfer(user_id)
123
164
  end
124
165
 
125
- desc 'saferegister', 'register SAFE network'
126
- option :spend_key, type: :string, required: true, desc: 'spend_key'
166
+ desc 'saferegister', 'Register on SAFE network'
167
+ option :spend_key, type: :string, required: true, desc: 'Spend private key'
127
168
  option :keystore, type: :string, aliases: '-k', required: true, desc: 'keystore or keystore.json file path'
128
169
  def saferegister
129
- res = api_instance.safe_register options[:spend_key]
130
- log res
170
+ with_command_name('saferegister') do
171
+ setup_api_instance!
172
+ res = api_instance.safe_register options[:spend_key]
173
+ print_result(res, command: 'saferegister')
174
+ end
175
+ rescue MixinBot::Error => e
176
+ abort_with_error(e.message, exception: e)
131
177
  end
132
178
 
133
- desc 'pay', 'generate payment url'
134
- option :members, type: :array, required: true, desc: 'Reveivers, maybe multisig'
135
- option :threshold, type: :numeric, required: false, default: 1, desc: 'Threshold of multisig'
179
+ desc 'pay', 'Generate Safe payment URL'
180
+ option :members, type: :array, required: true, desc: 'Receivers (supports multisig)'
181
+ option :threshold, type: :numeric, required: false, default: 1, desc: 'Multisig threshold'
136
182
  option :asset, type: :string, required: true, desc: 'Asset ID'
137
- option :amount, type: :numeric, required: true, desc: 'Amount'
183
+ option :amount, type: :string, required: true, desc: 'Amount'
138
184
  option :trace, type: :string, required: false, desc: 'Trace ID'
139
- option :memo, type: :string, required: false, desc: 'memo'
185
+ option :memo, type: :string, required: false, desc: 'Memo'
140
186
  def pay
141
- url = api_instance.safe_pay_url(
142
- members: options[:members],
143
- threshold: options[:threshold],
144
- asset_id: options[:asset],
145
- amount: options[:amount],
146
- trace_id: options[:trace],
147
- memo: options[:memo]
148
- )
187
+ with_command_name('pay') do
188
+ setup_api_instance!
189
+ url = api_instance.safe_pay_url(
190
+ members: options[:members],
191
+ threshold: options[:threshold],
192
+ asset_id: options[:asset],
193
+ amount: options[:amount],
194
+ trace_id: options[:trace],
195
+ memo: options[:memo]
196
+ )
149
197
 
150
- log UI.fmt "{{v}} #{url}"
198
+ emit_success({ 'url' => url }, command: 'pay')
199
+ end
200
+ rescue MixinBot::Error => e
201
+ abort_with_error(e.message, exception: e)
151
202
  end
152
203
 
153
- desc 'safetransfer USER_ID', 'transfer asset to USER_ID with SAFE network'
154
- option :asset, type: :string, required: true, desc: 'Asset ID'
155
- option :amount, type: :numeric, required: true, desc: 'Amount'
156
- option :trace, type: :string, required: false, desc: 'Trace ID'
157
- option :memo, type: :string, required: false, desc: 'memo'
158
- option :keystore, type: :string, aliases: '-k', required: true, desc: 'keystore or keystore.json file path'
159
- def safetransfer(user_id)
160
- amount = options[:amount].to_d
161
- asset = options[:asset]
162
- memo = options[:memo] || ''
204
+ private
205
+
206
+ def with_spinner(title)
207
+ if spinner_enabled?
208
+ CLI::UI::Spinner.spin(title) { |_spinner| yield }
209
+ else
210
+ yield
211
+ end
212
+ end
213
+
214
+ def spinner_enabled?
215
+ ENV['MIXINBOT_NO_SPINNER'].to_s != '1' && $stdout.tty? && !structured_output?
216
+ end
163
217
 
164
- # step 1: select inputs
165
- outputs = api_instance.safe_outputs(state: 'unspent', asset_id: asset, limit: 500)['data'].sort_by { |o| o['amount'].to_d }
166
- balance = outputs.sum(&->(output) { output['amount'].to_d })
218
+ def parse_api_body(json_string)
219
+ return nil if json_string.blank? || json_string == '{}'
167
220
 
168
- utxos = []
169
- outputs.each do |output|
170
- break if utxos.sum { |o| o['amount'].to_d } >= amount
221
+ JSON.parse(json_string)
222
+ rescue JSON::ParserError => e
223
+ abort_with_error("invalid JSON body: #{e.message}", kind: :invalid_args)
224
+ end
225
+
226
+ def post_via_client(path, payload, access_token: nil)
227
+ client = api_instance.client
228
+ token_opt = { access_token: access_token.presence }.compact
229
+
230
+ if payload.is_a?(Array)
231
+ client.fetch_post_array(path, payload, **token_opt)
232
+ elsif payload.is_a?(Hash)
233
+ client.fetch_post(path, body: payload, **token_opt)
234
+ elsif payload.nil?
235
+ client.post(path, **token_opt)
236
+ else
237
+ abort_with_error('POST body must be a JSON object or array', kind: :invalid_args)
238
+ end
239
+ end
240
+
241
+ def build_transfer_opts(user_id)
242
+ transfer_opts = {
243
+ members: [user_id],
244
+ threshold: 1,
245
+ asset_id: options[:asset],
246
+ amount: options[:amount].to_s,
247
+ memo: options[:memo] || ''
248
+ }
249
+ transfer_opts[:trace_id] = options[:trace] if options[:trace].present?
250
+ spend = options[:spend_key] || keystore&.dig('spend_key')
251
+ transfer_opts[:spend_key] = spend if spend.present?
252
+ transfer_opts
253
+ end
171
254
 
172
- utxos.shift if utxos.size >= 255
173
- utxos << output
255
+ def perform_safe_transfer(user_id)
256
+ transfer_opts = build_transfer_opts(user_id)
257
+
258
+ if options[:dry_run]
259
+ emit_success(
260
+ { 'dry_run' => true, 'method' => 'create_safe_transfer', 'kwargs' => transfer_opts.transform_keys(&:to_s) },
261
+ command: 'transfer'
262
+ )
263
+ return
264
+ end
265
+
266
+ res = with_spinner(
267
+ "Safe transfer #{transfer_opts[:amount]} #{transfer_opts[:asset_id]} to #{user_id}"
268
+ ) do
269
+ api_instance.create_safe_transfer(**transfer_opts)
174
270
  end
175
271
 
176
- log UI.fmt "Step 1/7: {{v}} Found #{outputs.count} unspent outputs, balance: #{balance}, selected #{utxos.count} outputs"
177
-
178
- # step 2: build transaction
179
- tx = api_instance.build_safe_transaction(
180
- utxos:,
181
- receivers: [
182
- members: [user_id],
183
- threshold: 1,
184
- amount:
185
- ],
186
- extra: memo
187
- )
188
- raw = MixinBot::Utils.encode_raw_transaction tx
189
- log UI.fmt "Step 2/5: {{v}} Built raw: #{raw}"
190
-
191
- # step 3: verify transaction
192
- request_id = SecureRandom.uuid
193
- request = api_instance.create_safe_transaction_request(request_id, raw)['data']
194
- log UI.fmt "Step 3/5: {{v}} Verified transaction, request_id: #{request[0]['request_id']}"
195
-
196
- # step 4: sign transaction
197
- signed_raw = api_instance.sign_safe_transaction(
198
- raw:,
199
- utxos:,
200
- request: request[0]
201
- )
202
- log UI.fmt "Step 4/5: {{v}} Signed transaction: #{signed_raw}"
203
-
204
- # step 5: submit transaction
205
- r = api_instance.send_safe_transaction(
206
- request_id,
207
- signed_raw
208
- )
209
- log UI.fmt "Step 5/5: {{v}} Submit transaction, hash: #{r['data'].first['transaction_hash']}"
210
- rescue StandardError => e
211
- log UI.fmt "{{x}} #{e.inspect}"
272
+ emit_transfer_result(res)
273
+ rescue MixinBot::Error => e
274
+ abort_with_error(e.message, exception: e)
275
+ end
276
+
277
+ def emit_transfer_result(res)
278
+ data = res['data'] if res.respond_to?(:[])
279
+ tx_hash = data.is_a?(Array) ? data.first&.dig('transaction_hash') : nil
280
+ snapshot_id = res['snapshot_id'] if res.respond_to?(:[])
281
+
282
+ if tx_hash.present?
283
+ payload = { 'transaction_hash' => tx_hash }
284
+ emit_info("Submitted: transaction_hash=#{tx_hash}")
285
+ emit_success(payload, command: current_command_name)
286
+ elsif snapshot_id.present?
287
+ url = "https://mixin.one/snapshots/#{snapshot_id}"
288
+ emit_info("Finished: #{url}")
289
+ emit_success({ 'snapshot_id' => snapshot_id, 'url' => url }, command: current_command_name)
290
+ else
291
+ print_result(res, command: current_command_name)
292
+ end
212
293
  end
213
294
  end
214
295
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ ##
5
+ # Registry and predicates for mixinbot `call` / `list` commands.
6
+ #
7
+ module CLIHelpers
8
+ API_EXCLUDED_METHODS = %i[
9
+ initialize
10
+ client
11
+ config
12
+ utils
13
+ client_id
14
+ access_token
15
+ sign_authentication_token
16
+ sign_authentication_token_without_body
17
+ sign_authentication_token_with_request_id
18
+ encode_raw_transaction
19
+ decode_raw_transaction
20
+ generate_trace_from_hash
21
+ encode_raw_transaction_native
22
+ decode_raw_transaction_native
23
+ warn_legacy_mixin_api!
24
+ ensure_mixin_command_exist
25
+ command?
26
+ ].freeze
27
+
28
+ INTERACTIVE_API_METHODS = %i[
29
+ start_blaze_connect
30
+ blaze
31
+ upload_attachment
32
+ ].freeze
33
+
34
+ module_function
35
+
36
+ def api_callable_methods
37
+ methods = MixinBot::API.instance_methods(false)
38
+ MixinBot::API.included_modules.each do |mod|
39
+ next unless mod.name&.start_with?('MixinBot::API::')
40
+
41
+ methods.concat(mod.instance_methods(false))
42
+ end
43
+
44
+ methods
45
+ .map(&:to_sym)
46
+ .uniq
47
+ .select { |m| api_method_callable?(m) }
48
+ .sort
49
+ end
50
+
51
+ def api_method_callable?(method_name)
52
+ sym = method_name.to_sym
53
+ return false if API_EXCLUDED_METHODS.include?(sym)
54
+ return false if INTERACTIVE_API_METHODS.include?(sym)
55
+
56
+ MixinBot::API.method_defined?(sym) &&
57
+ !sym.to_s.start_with?('_')
58
+ end
59
+
60
+ def utils_callable_methods
61
+ MixinBot::Utils.singleton_methods(false).sort
62
+ end
63
+
64
+ def api_method_owner(method_name)
65
+ sym = method_name.to_sym
66
+ return 'MixinBot::API' if MixinBot::API.method_defined?(sym, false)
67
+
68
+ MixinBot::API.included_modules.find do |mod|
69
+ mod.name&.start_with?('MixinBot::API::') && mod.method_defined?(sym, false)
70
+ end&.name || 'MixinBot::API'
71
+ end
72
+
73
+ def grouped_api_methods
74
+ api_callable_methods.group_by { |m| api_method_owner(m) }.sort_by { |k, _| k }
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ class CLI
5
+ desc 'call METHOD', 'Invoke a MixinBot::API method with JSON keyword arguments'
6
+ long_desc <<-LONGDESC
7
+ Invoke any public API method (see `mixinbot list`).
8
+
9
+ Examples:
10
+
11
+ $ mixinbot call me -k ~/.mixinbot/keystore.json
12
+ $ mixinbot call safe_outputs -k keystore.json -d '{"asset":"...","state":"unspent","limit":10}'
13
+ $ mixinbot call user USER_ID -k keystore.json
14
+ $ mixinbot call create_transfer -k keystore.json -d '{"members":"uuid","asset_id":"...","amount":"0.01"}'
15
+ LONGDESC
16
+ option :keystore, type: :string, aliases: '-k', desc: 'keystore JSON file path or inline JSON'
17
+ option :data, type: :string, aliases: '-d', default: '{}', desc: 'JSON object of keyword arguments'
18
+ option :data_only, type: :boolean, default: false, desc: 'Print only the data field of API responses'
19
+ def call(method_name, *positional)
20
+ with_command_name('call') do
21
+ setup_api_instance!
22
+ kwargs = parse_json_data(options[:data])
23
+ result = invoke_api(method_name, kwargs:, positional:)
24
+ print_result(result, data_only: options[:data_only], command: 'call')
25
+ end
26
+ end
27
+
28
+ desc 'list [FILTER]', 'List callable MixinBot::API methods (optional substring filter)'
29
+ option :limit, type: :numeric, default: 100, desc: 'Maximum items to return'
30
+ option :offset, type: :numeric, default: 0, desc: 'Number of items to skip'
31
+ option :fields, type: :string, desc: 'Comma-separated fields for JSON output (name,owner)'
32
+ def list(filter = nil)
33
+ with_command_name('list') do
34
+ methods = CLIHelpers.api_callable_methods
35
+ if filter.present?
36
+ needle = filter.downcase
37
+ methods = methods.select { |m| m.to_s.downcase.include?(needle) }
38
+ end
39
+
40
+ items = methods.map do |name|
41
+ { 'name' => name.to_s, 'owner' => CLIHelpers.api_method_owner(name) }
42
+ end
43
+ items = items.sort_by { |item| [item['owner'], item['name']] }
44
+
45
+ page, total, limit, offset = paginate_items(items, limit: options[:limit], offset: options[:offset])
46
+ page = select_fields(page, options[:fields])
47
+
48
+ if structured_output?
49
+ emit_list(items: page, total:, limit:, offset:, command: 'list')
50
+ else
51
+ print_pretty_list(page, total, limit, offset)
52
+ end
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def print_pretty_list(items, total, limit, offset)
59
+ grouped = items.group_by { |item| item['owner'] }
60
+ grouped.sort_by { |owner, _| owner }.each do |owner, names|
61
+ puts "#{owner}:"
62
+ names.sort_by { |n| n['name'] }.each { |n| puts " #{n['name']}" }
63
+ puts
64
+ end
65
+
66
+ return unless total > limit || offset.positive?
67
+
68
+ emit_info("Showing #{items.size} of #{total} (limit=#{limit}, offset=#{offset})")
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ ##
5
+ # Maps exceptions and messages to clispec error kinds for structured CLI output.
6
+ #
7
+ module CLIErrors
8
+ ERROR_KINDS = {
9
+ invalid_args: { retryable: false, description: 'Invalid or missing arguments' },
10
+ auth: { retryable: false, description: 'Authentication or authorization failed' },
11
+ not_found: { retryable: false, description: 'Requested resource was not found' },
12
+ api_error: { retryable: false, description: 'Mixin API returned an error' },
13
+ unsupported: { retryable: false, description: 'Operation is not supported in this context' },
14
+ conflict: { retryable: false, description: 'Resource exists with incompatible configuration' },
15
+ internal: { retryable: false, description: 'Unexpected internal error' }
16
+ }.freeze
17
+
18
+ module_function
19
+
20
+ def schema_errors
21
+ ERROR_KINDS.map do |kind, meta|
22
+ {
23
+ 'kind' => kind.to_s,
24
+ 'retryable' => meta[:retryable],
25
+ 'description' => meta[:description]
26
+ }
27
+ end
28
+ end
29
+
30
+ def kind_for_exception(error)
31
+ case error
32
+ when MixinBot::ArgumentError, ::ArgumentError
33
+ :invalid_args
34
+ when UnauthorizedError, ForbiddenError, PinError, ConfigurationNotValidError
35
+ :auth
36
+ when NotFoundError, UserNotFoundError
37
+ :not_found
38
+ when ResponseError, RequestError, HttpError,
39
+ InsufficientBalanceError, UtxoInsufficientError, InsufficientPoolError
40
+ :api_error
41
+ else
42
+ :internal
43
+ end
44
+ end
45
+
46
+ def kind_for_message(message)
47
+ msg = message.to_s.downcase
48
+ return :auth if msg.include?('unauthorized') || msg.include?('authentication')
49
+ return :not_found if msg.include?('not found') || msg.include?('404')
50
+ return :unsupported if msg.include?('unsupported') || msg.include?('not supported')
51
+ return :invalid_args if msg.include?('invalid') || msg.include?('unknown')
52
+
53
+ :internal
54
+ end
55
+ end
56
+ end