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.
- checksums.yaml +4 -4
- data/AGENTS.md +75 -0
- data/API_COVERAGE.md +143 -0
- data/CHANGELOG.md +112 -0
- data/README.md +375 -0
- data/docs/agent/cli.md +149 -0
- data/docs/agent/cookbook.md +152 -0
- data/examples/blaze.rb +43 -0
- data/examples/config.yml.example +21 -0
- data/lib/mixin_bot/address.rb +43 -3
- data/lib/mixin_bot/api/app.rb +7 -0
- data/lib/mixin_bot/api/asset.rb +114 -3
- data/lib/mixin_bot/api/auth.rb +19 -10
- data/lib/mixin_bot/api/blaze.rb +81 -0
- data/lib/mixin_bot/api/chain.rb +94 -0
- data/lib/mixin_bot/api/code.rb +16 -0
- data/lib/mixin_bot/api/computer_api.rb +60 -0
- data/lib/mixin_bot/api/conversation.rb +7 -1
- data/lib/mixin_bot/api/deposit.rb +12 -0
- data/lib/mixin_bot/api/encrypted_message.rb +1 -1
- data/lib/mixin_bot/api/fiat.rb +12 -0
- data/lib/mixin_bot/api/inscription.rb +2 -2
- data/lib/mixin_bot/api/legacy_collectible.rb +26 -27
- data/lib/mixin_bot/api/legacy_multisig.rb +20 -21
- data/lib/mixin_bot/api/legacy_output.rb +10 -3
- data/lib/mixin_bot/api/legacy_payment.rb +2 -0
- data/lib/mixin_bot/api/legacy_snapshot.rb +16 -0
- data/lib/mixin_bot/api/legacy_transaction.rb +28 -13
- data/lib/mixin_bot/api/legacy_transfer.rb +11 -8
- data/lib/mixin_bot/api/legacy_user.rb +51 -0
- data/lib/mixin_bot/api/me.rb +99 -3
- data/lib/mixin_bot/api/message.rb +18 -27
- data/lib/mixin_bot/api/multisig.rb +19 -0
- data/lib/mixin_bot/api/network.rb +17 -0
- data/lib/mixin_bot/api/network_asset.rb +27 -0
- data/lib/mixin_bot/api/output.rb +1 -1
- data/lib/mixin_bot/api/pin.rb +16 -3
- data/lib/mixin_bot/api/pin_payload.rb +26 -0
- data/lib/mixin_bot/api/session.rb +14 -0
- data/lib/mixin_bot/api/snapshot.rb +6 -0
- data/lib/mixin_bot/api/tip.rb +74 -1
- data/lib/mixin_bot/api/transaction.rb +106 -17
- data/lib/mixin_bot/api/transfer.rb +141 -14
- data/lib/mixin_bot/api/turn.rb +12 -0
- data/lib/mixin_bot/api/user.rb +148 -45
- data/lib/mixin_bot/api/withdraw.rb +24 -23
- data/lib/mixin_bot/api.rb +248 -3
- data/lib/mixin_bot/bot_auth.rb +71 -0
- data/lib/mixin_bot/cli/api.rb +224 -143
- data/lib/mixin_bot/cli/base.rb +77 -0
- data/lib/mixin_bot/cli/call.rb +71 -0
- data/lib/mixin_bot/cli/errors.rb +56 -0
- data/lib/mixin_bot/cli/node.rb +9 -2
- data/lib/mixin_bot/cli/output.rb +196 -0
- data/lib/mixin_bot/cli/schema.rb +274 -0
- data/lib/mixin_bot/cli/schema_command.rb +21 -0
- data/lib/mixin_bot/cli/utils.rb +114 -18
- data/lib/mixin_bot/cli.rb +124 -48
- data/lib/mixin_bot/client/error_mapper.rb +40 -0
- data/lib/mixin_bot/client.rb +94 -64
- data/lib/mixin_bot/computer.rb +132 -0
- data/lib/mixin_bot/configuration.rb +108 -1
- data/lib/mixin_bot/errors.rb +102 -0
- data/lib/mixin_bot/models/address.rb +11 -0
- data/lib/mixin_bot/models/api_envelope.rb +67 -0
- data/lib/mixin_bot/models/asset.rb +11 -0
- data/lib/mixin_bot/models/ghost_keys.rb +14 -0
- data/lib/mixin_bot/models/output.rb +11 -0
- data/lib/mixin_bot/models/safe_multisig_request.rb +11 -0
- data/lib/mixin_bot/models/sequencer_transaction_request.rb +11 -0
- data/lib/mixin_bot/models/user.rb +11 -0
- data/lib/mixin_bot/models.rb +10 -0
- data/lib/mixin_bot/monitor.rb +77 -0
- data/lib/mixin_bot/transaction/buffer.rb +34 -0
- data/lib/mixin_bot/transaction/decoder.rb +227 -0
- data/lib/mixin_bot/transaction/encoder.rb +255 -0
- data/lib/mixin_bot/transaction.rb +6 -475
- data/lib/mixin_bot/url_scheme.rb +63 -0
- data/lib/mixin_bot/utils/address.rb +17 -80
- data/lib/mixin_bot/utils/crypto.rb +173 -1
- data/lib/mixin_bot/utils/decoder.rb +1 -1
- data/lib/mixin_bot/utils/encoder.rb +13 -0
- data/lib/mixin_bot/utils.rb +45 -0
- data/lib/mixin_bot/uuid.rb +78 -1
- data/lib/mixin_bot/version.rb +11 -1
- data/lib/mixin_bot.rb +172 -18
- data/lib/mvm/bridge.rb +46 -0
- data/lib/mvm/client.rb +60 -0
- data/lib/mvm/nft.rb +4 -2
- data/lib/mvm/registry.rb +2 -1
- data/lib/mvm.rb +93 -0
- data/lib/tasks/api_coverage.rake +20 -0
- data/llms.txt +29 -0
- metadata +77 -9
data/lib/mixin_bot/cli/api.rb
CHANGED
|
@@ -1,214 +1,295 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module MixinBot
|
|
4
|
-
class CLI
|
|
5
|
-
desc 'api PATH', 'request
|
|
4
|
+
class CLI
|
|
5
|
+
desc 'api PATH', 'Signed GET/POST request to a Mixin API path'
|
|
6
6
|
long_desc <<-LONGDESC
|
|
7
|
-
|
|
7
|
+
Use `mixinbot api PATH` to call any Mixin REST endpoint via MixinBot::Client.
|
|
8
8
|
|
|
9
|
-
Get user
|
|
9
|
+
Get user information:
|
|
10
10
|
|
|
11
11
|
$ mixinbot api /me -k ~/.mixinbot/keystore.json
|
|
12
12
|
|
|
13
|
-
Search user
|
|
13
|
+
Search user:
|
|
14
14
|
|
|
15
15
|
$ mixinbot api /search/1051445 -k ~/.mixinbot/keystore.json
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
POST with JSON body:
|
|
18
18
|
|
|
19
|
-
$ mixinbot api /payments -k
|
|
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
|
|
23
|
-
option :params, type: :hash, aliases: '-p', desc: 'HTTP GET
|
|
24
|
-
option :data, type: :string, aliases: '-d', default: '{}', desc: 'HTTP POST
|
|
25
|
-
option :accesstoken, type: :string, aliases: '-t', desc: '
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
+
rescue MixinBot::Error => e
|
|
52
|
+
abort_with_error(e.message, exception: e)
|
|
51
53
|
end
|
|
52
54
|
|
|
53
|
-
desc 'authcode', 'code
|
|
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: '
|
|
56
|
-
option :scope, type: :array, default: ['PROFILE:READ'], aliases: '-s', desc: '
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
71
|
+
rescue MixinBot::Error => e
|
|
72
|
+
abort_with_error(e.message, exception: e)
|
|
68
73
|
end
|
|
69
74
|
|
|
70
|
-
desc 'updatetip 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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
counter = profile['tip_counter']
|
|
84
|
+
key = api_instance.prepare_tip_key counter
|
|
85
|
+
emit_info("Generated key: #{key[:private_key]}")
|
|
79
86
|
|
|
80
|
-
|
|
87
|
+
res = api_instance.update_tip_pin(pin.to_s, key[:public_key])
|
|
81
88
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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', '
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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: :
|
|
103
|
-
option :memo, type: :string, required: false, desc: '
|
|
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
|
-
|
|
122
|
+
with_command_name('transfer') do
|
|
123
|
+
setup_api_instance!
|
|
124
|
+
perform_safe_transfer(user_id)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
107
127
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', '
|
|
126
|
-
option :spend_key, type: :string, required: true, desc: '
|
|
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
|
-
|
|
130
|
-
|
|
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', '
|
|
134
|
-
option :members, type: :array, required: true, desc: '
|
|
135
|
-
option :threshold, type: :numeric, required: false, default: 1, desc: '
|
|
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: :
|
|
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: '
|
|
185
|
+
option :memo, type: :string, required: false, desc: 'Memo'
|
|
140
186
|
def pay
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|