mixin_bot 1.4.0 → 2.1.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 +220 -0
- data/CHANGELOG.md +108 -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 +75 -0
- data/lib/mixin_bot/api/asset.rb +114 -3
- data/lib/mixin_bot/api/auth.rb +29 -9
- data/lib/mixin_bot/api/blaze.rb +81 -0
- data/lib/mixin_bot/api/chain.rb +94 -0
- data/lib/mixin_bot/api/circle.rb +57 -0
- data/lib/mixin_bot/api/code.rb +21 -0
- data/lib/mixin_bot/api/computer_api.rb +60 -0
- data/lib/mixin_bot/api/conversation.rb +33 -10
- data/lib/mixin_bot/api/deposit.rb +19 -0
- data/lib/mixin_bot/api/encrypted_message.rb +1 -1
- data/lib/mixin_bot/api/external.rb +12 -0
- 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 +120 -3
- data/lib/mixin_bot/api/message.rb +66 -28
- 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 +29 -23
- data/lib/mixin_bot/api.rb +252 -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 +30 -0
- metadata +79 -9
|
@@ -36,7 +36,7 @@ module MixinBot
|
|
|
36
36
|
recipients.each_with_index do |recipient, index|
|
|
37
37
|
next if recipient[:mix_address].blank?
|
|
38
38
|
|
|
39
|
-
if recipient[:members].all?(&->(m) { m.start_with? MixinBot::
|
|
39
|
+
if recipient[:members].all?(&->(m) { m.start_with? MixinBot::MAIN_ADDRESS_PREFIX })
|
|
40
40
|
key = JOSE::JWA::Ed25519.keypair
|
|
41
41
|
gk = {
|
|
42
42
|
mask: key[0].unpack1('H*'),
|
|
@@ -54,7 +54,7 @@ module MixinBot
|
|
|
54
54
|
|
|
55
55
|
ghost_keys[index] = gk.with_indifferent_access
|
|
56
56
|
|
|
57
|
-
elsif recipient[:members].none?(&->(m) { m.start_with? MixinBot::
|
|
57
|
+
elsif recipient[:members].none?(&->(m) { m.start_with? MixinBot::MAIN_ADDRESS_PREFIX })
|
|
58
58
|
uuid_recipients.push(
|
|
59
59
|
{
|
|
60
60
|
receivers: recipient[:members],
|
|
@@ -88,7 +88,12 @@ module MixinBot
|
|
|
88
88
|
# }
|
|
89
89
|
SAFE_RAW_TRANSACTION_ARGUMENTS = %i[utxos receivers].freeze
|
|
90
90
|
def build_safe_transaction(**kwargs)
|
|
91
|
-
|
|
91
|
+
unless SAFE_RAW_TRANSACTION_ARGUMENTS.all? do |param|
|
|
92
|
+
kwargs.keys.include? param
|
|
93
|
+
end
|
|
94
|
+
raise ArgumentError,
|
|
95
|
+
"#{SAFE_RAW_TRANSACTION_ARGUMENTS.join(', ')} are needed for build safe transaction"
|
|
96
|
+
end
|
|
92
97
|
raise ArgumentError, 'receivers should be an array' unless kwargs[:receivers].is_a? Array
|
|
93
98
|
raise ArgumentError, 'utxos should be an array' unless kwargs[:utxos].is_a? Array
|
|
94
99
|
|
|
@@ -126,10 +131,20 @@ module MixinBot
|
|
|
126
131
|
end
|
|
127
132
|
raise ArgumentError, 'recipients too many' if recipients.size > 256
|
|
128
133
|
|
|
129
|
-
|
|
134
|
+
mixin_asset_for = lambda do |u|
|
|
135
|
+
h = u.with_indifferent_access
|
|
136
|
+
next h[:asset] if h[:asset].present?
|
|
137
|
+
|
|
138
|
+
aid = h[:asset_id]
|
|
139
|
+
raise ArgumentError, 'utxo asset_id or asset is required' if aid.blank?
|
|
140
|
+
|
|
141
|
+
SHA3::Digest::SHA256.hexdigest(aid)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
asset = mixin_asset_for.call(utxos[0])
|
|
130
145
|
inputs = []
|
|
131
146
|
utxos.each do |utxo|
|
|
132
|
-
raise ArgumentError, 'utxo asset not match' unless utxo
|
|
147
|
+
raise ArgumentError, 'utxo asset not match' unless mixin_asset_for.call(utxo) == asset
|
|
133
148
|
|
|
134
149
|
inputs << {
|
|
135
150
|
hash: utxo['transaction_hash'],
|
|
@@ -171,6 +186,17 @@ module MixinBot
|
|
|
171
186
|
}
|
|
172
187
|
end
|
|
173
188
|
|
|
189
|
+
def verify_raw_transaction(requests)
|
|
190
|
+
requests = Array(requests).map do |r|
|
|
191
|
+
r = r.with_indifferent_access
|
|
192
|
+
{ request_id: r[:request_id], raw: r[:raw] }
|
|
193
|
+
end
|
|
194
|
+
create_safe_transaction_request(requests.first[:request_id], requests.first[:raw]) if requests.one?
|
|
195
|
+
|
|
196
|
+
path = '/safe/transaction/requests'
|
|
197
|
+
client.post path, *requests
|
|
198
|
+
end
|
|
199
|
+
|
|
174
200
|
def create_safe_transaction_request(request_id, raw)
|
|
175
201
|
path = '/safe/transaction/requests'
|
|
176
202
|
payload = [{
|
|
@@ -181,25 +207,73 @@ module MixinBot
|
|
|
181
207
|
client.post path, *payload
|
|
182
208
|
end
|
|
183
209
|
|
|
184
|
-
def send_safe_transaction(request_id, raw)
|
|
210
|
+
def send_safe_transaction(request_id, raw = nil, requests: nil)
|
|
185
211
|
path = '/safe/transactions'
|
|
186
|
-
payload =
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
212
|
+
payload =
|
|
213
|
+
if requests.present?
|
|
214
|
+
Array(requests).map { |r| r.with_indifferent_access.slice(:request_id, :raw) }
|
|
215
|
+
else
|
|
216
|
+
[{ request_id:, raw: }]
|
|
217
|
+
end
|
|
190
218
|
|
|
191
219
|
client.post path, *payload
|
|
192
220
|
end
|
|
221
|
+
alias send_raw_transaction send_safe_transaction
|
|
193
222
|
|
|
194
223
|
def safe_transaction(request_id, access_token: nil)
|
|
195
224
|
path = format('/safe/transactions/%<request_id>s', request_id:)
|
|
196
225
|
|
|
197
226
|
client.get path, access_token:
|
|
198
227
|
end
|
|
228
|
+
alias get_transaction_by_id safe_transaction
|
|
229
|
+
alias get_transaction_by_id_with_safe_user safe_transaction
|
|
230
|
+
|
|
231
|
+
def estimate_storage_cost(extra)
|
|
232
|
+
step = BigDecimal(EXTRA_STORAGE_PRICE_STEP)
|
|
233
|
+
len = extra.to_s.bytesize
|
|
234
|
+
steps = (len / 1024) + 1
|
|
235
|
+
step * steps
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def storage_recipient
|
|
239
|
+
MixinBot::MixAddress.from_members(
|
|
240
|
+
members: [MixinBot.utils.burning_address],
|
|
241
|
+
threshold: 64
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def create_object_storage_transaction(extra:, trace_id:, _references: nil, limit: nil, utxos: nil, **transfer_opts)
|
|
246
|
+
amount = estimate_storage_cost(extra)
|
|
247
|
+
amount = [amount, BigDecimal(limit.to_s)].max if limit.present?
|
|
248
|
+
kwargs = {
|
|
249
|
+
asset_id: XIN_ASSET_ID,
|
|
250
|
+
amount: amount.to_s('F'),
|
|
251
|
+
trace_id:,
|
|
252
|
+
memo: extra.to_s,
|
|
253
|
+
**transfer_opts
|
|
254
|
+
}
|
|
255
|
+
kwargs[:utxos] = utxos if utxos.present?
|
|
256
|
+
kwargs[:members] = [config.app_id] unless kwargs.key?(:members)
|
|
257
|
+
create_safe_transfer(**kwargs)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def request_ghost_recipients_with_trace_id(recipients, _trace_id)
|
|
261
|
+
generate_safe_keys(
|
|
262
|
+
recipients.map do |r|
|
|
263
|
+
r = r.with_indifferent_access
|
|
264
|
+
{ members: r[:members], threshold: r[:threshold], mix_address: r[:mix_address] }
|
|
265
|
+
end
|
|
266
|
+
)
|
|
267
|
+
end
|
|
199
268
|
|
|
200
269
|
SIGN_SAFE_TRANSACTION_ARGUMENTS = %i[raw utxos request spend_key].freeze
|
|
201
270
|
def sign_safe_transaction(**kwargs)
|
|
202
|
-
|
|
271
|
+
unless SIGN_SAFE_TRANSACTION_ARGUMENTS.all? do |param|
|
|
272
|
+
kwargs.keys.include? param
|
|
273
|
+
end
|
|
274
|
+
raise ArgumentError,
|
|
275
|
+
"#{SIGN_SAFE_TRANSACTION_ARGUMENTS.join(', ')} are needed for sign safe transaction"
|
|
276
|
+
end
|
|
203
277
|
|
|
204
278
|
raw = kwargs[:raw]
|
|
205
279
|
tx = MixinBot.utils.decode_raw_transaction raw
|
|
@@ -265,7 +339,12 @@ module MixinBot
|
|
|
265
339
|
|
|
266
340
|
INSCRIBE_TRANSACTION_ARGUMENTS = %i[content collection_hash].freeze
|
|
267
341
|
def build_inscribe_transaction(**kwargs)
|
|
268
|
-
|
|
342
|
+
unless INSCRIBE_TRANSACTION_ARGUMENTS.all? do |param|
|
|
343
|
+
kwargs.keys.include? param
|
|
344
|
+
end
|
|
345
|
+
raise ArgumentError,
|
|
346
|
+
"#{INSCRIBE_TRANSACTION_ARGUMENTS.join(', ')} are needed for inscribe transaction"
|
|
347
|
+
end
|
|
269
348
|
|
|
270
349
|
receivers = kwargs[:receivers].presence || [config.app_id]
|
|
271
350
|
receivers_threshold = kwargs[:receivers_threshold] || receivers.length
|
|
@@ -283,14 +362,20 @@ module MixinBot
|
|
|
283
362
|
MixinBot.api.build_object_transaction data.to_json, references: [collection_hash]
|
|
284
363
|
end
|
|
285
364
|
|
|
286
|
-
OCCUPY_INSCRIPTION_TRANSACTION_ARGUMENTS = %i[amount inscription_hash utxos].freeze
|
|
365
|
+
OCCUPY_INSCRIPTION_TRANSACTION_ARGUMENTS = %i[amount inscription_hash sequence utxos].freeze
|
|
287
366
|
def build_occupy_transaction(**kwargs)
|
|
288
|
-
|
|
367
|
+
unless OCCUPY_INSCRIPTION_TRANSACTION_ARGUMENTS.all? do |param|
|
|
368
|
+
kwargs.keys.include? param
|
|
369
|
+
end
|
|
370
|
+
raise ArgumentError,
|
|
371
|
+
"#{OCCUPY_INSCRIPTION_TRANSACTION_ARGUMENTS.join(', ')} are needed for occupy NFT transaction"
|
|
372
|
+
end
|
|
289
373
|
|
|
290
374
|
members = kwargs[:members].presence || [config.app_id]
|
|
291
375
|
threshold = kwargs[:threshold] || members.length
|
|
292
376
|
amount = kwargs[:amount]
|
|
293
377
|
inscription_hash = kwargs[:inscription_hash]
|
|
378
|
+
sequence = kwargs[:sequence]
|
|
294
379
|
|
|
295
380
|
receivers = [
|
|
296
381
|
{
|
|
@@ -302,11 +387,15 @@ module MixinBot
|
|
|
302
387
|
|
|
303
388
|
extra = {
|
|
304
389
|
operation: 'occupy',
|
|
305
|
-
|
|
306
|
-
content:
|
|
390
|
+
sequence:
|
|
307
391
|
}.to_json
|
|
308
392
|
|
|
309
|
-
|
|
393
|
+
build_safe_transaction(utxos: kwargs[:utxos], receivers:, extra:, references: [inscription_hash])
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def send_kernel_transaction_from_account(**_kwargs)
|
|
397
|
+
raise NotImplementedError,
|
|
398
|
+
'send_kernel_transaction_from_account requires kernel UTXO signing; use native mixin CLI or build manually'
|
|
310
399
|
end
|
|
311
400
|
end
|
|
312
401
|
end
|
|
@@ -2,18 +2,108 @@
|
|
|
2
2
|
|
|
3
3
|
module MixinBot
|
|
4
4
|
class API
|
|
5
|
+
##
|
|
6
|
+
# API methods for creating transfers using the Safe API.
|
|
7
|
+
#
|
|
8
|
+
# The Safe API is the recommended way to transfer assets on Mixin Network.
|
|
9
|
+
# It provides better security, lower fees, and more flexibility than legacy transfers.
|
|
10
|
+
#
|
|
11
|
+
# == Transfer Process
|
|
12
|
+
#
|
|
13
|
+
# A Safe transfer involves several steps:
|
|
14
|
+
# 1. Select UTXOs (unspent transaction outputs) as inputs
|
|
15
|
+
# 2. Build the transaction with outputs
|
|
16
|
+
# 3. Sign the transaction with spend key
|
|
17
|
+
# 4. Submit the signed transaction
|
|
18
|
+
#
|
|
19
|
+
# This module handles all these steps automatically.
|
|
20
|
+
#
|
|
21
|
+
# == Usage
|
|
22
|
+
#
|
|
23
|
+
# # Simple transfer to a single user
|
|
24
|
+
# api.create_transfer(
|
|
25
|
+
# members: 'user-uuid',
|
|
26
|
+
# asset_id: 'asset-uuid',
|
|
27
|
+
# amount: '0.01',
|
|
28
|
+
# memo: 'Payment'
|
|
29
|
+
# )
|
|
30
|
+
#
|
|
31
|
+
# # Multisig transfer
|
|
32
|
+
# api.create_transfer(
|
|
33
|
+
# members: ['user1-uuid', 'user2-uuid', 'user3-uuid'],
|
|
34
|
+
# threshold: 2,
|
|
35
|
+
# asset_id: 'asset-uuid',
|
|
36
|
+
# amount: '0.01'
|
|
37
|
+
# )
|
|
38
|
+
#
|
|
5
39
|
module Transfer
|
|
6
|
-
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
40
|
+
##
|
|
41
|
+
# Creates a Safe API transfer.
|
|
42
|
+
#
|
|
43
|
+
# This is the main method for sending assets on Mixin Network.
|
|
44
|
+
# It handles the complete transfer process including UTXO selection,
|
|
45
|
+
# transaction building, signing, and submission.
|
|
46
|
+
#
|
|
47
|
+
# @param kwargs [Hash] transfer options
|
|
48
|
+
# @option kwargs [String, Array<String>] :members recipient user ID(s)
|
|
49
|
+
# @option kwargs [Integer] :threshold multisig threshold (defaults to members.length)
|
|
50
|
+
# @option kwargs [String] :asset_id the asset UUID to transfer
|
|
51
|
+
# @option kwargs [String, Float] :amount the amount to transfer
|
|
52
|
+
# @option kwargs [String] :trace_id unique trace ID (defaults to random UUID)
|
|
53
|
+
# @option kwargs [String] :request_id alias for trace_id
|
|
54
|
+
# @option kwargs [String] :memo transaction memo (max 140 characters)
|
|
55
|
+
# @option kwargs [String] :spend_key spend private key (defaults to config.spend_key)
|
|
56
|
+
# @option kwargs [Array<Hash>] :utxos specific UTXOs to use (optional, will auto-select if not provided)
|
|
57
|
+
#
|
|
58
|
+
# @return [Hash] the transfer result including transaction hash and status
|
|
59
|
+
#
|
|
60
|
+
# @raise [ArgumentError] if required parameters are missing or invalid
|
|
61
|
+
# @raise [InsufficientBalanceError] if balance is insufficient
|
|
62
|
+
#
|
|
63
|
+
# @example Simple transfer
|
|
64
|
+
# result = api.create_transfer(
|
|
65
|
+
# members: '6ae1c7ae-1df1-498e-8f21-d48cb6d129b5',
|
|
66
|
+
# asset_id: '965e5c6e-434c-3fa9-b780-c50f43cd955c',
|
|
67
|
+
# amount: '0.01',
|
|
68
|
+
# memo: 'Test payment'
|
|
69
|
+
# )
|
|
70
|
+
# puts result['snapshot_id']
|
|
71
|
+
#
|
|
72
|
+
# @example Multisig transfer (2-of-3)
|
|
73
|
+
# result = api.create_transfer(
|
|
74
|
+
# members: [
|
|
75
|
+
# '6ae1c7ae-1df1-498e-8f21-d48cb6d129b5',
|
|
76
|
+
# '8017d200-7870-4b82-b53f-74bae1d2dad7',
|
|
77
|
+
# 'e8e8cd79-cd40-4796-8c54-3a13cfe50115'
|
|
78
|
+
# ],
|
|
79
|
+
# threshold: 2,
|
|
80
|
+
# asset_id: '965e5c6e-434c-3fa9-b780-c50f43cd955c',
|
|
81
|
+
# amount: '0.01'
|
|
82
|
+
# )
|
|
83
|
+
#
|
|
84
|
+
def create_transfer(pin = nil, **kwargs)
|
|
85
|
+
if pin.is_a?(String) && kwargs[:opponent_id].present?
|
|
86
|
+
create_legacy_transfer(pin, **kwargs)
|
|
87
|
+
else
|
|
88
|
+
create_safe_transfer(**(pin.is_a?(Hash) ? pin : kwargs))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def send_transaction(**)
|
|
93
|
+
create_safe_transfer(**)
|
|
94
|
+
end
|
|
95
|
+
alias send_transfer_transaction send_transaction
|
|
96
|
+
alias send_transaction_with_outputs send_transaction
|
|
97
|
+
|
|
98
|
+
def send_transaction_until_sufficient(**)
|
|
99
|
+
create_safe_transfer(**)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def send_transaction_with_change_outputs(**)
|
|
103
|
+
create_safe_transfer(**)
|
|
104
|
+
end
|
|
105
|
+
alias send_transaction_with_utxos_and_change_outputs send_transaction_with_change_outputs
|
|
106
|
+
|
|
17
107
|
def create_safe_transfer(**kwargs)
|
|
18
108
|
utxos = kwargs[:utxos]
|
|
19
109
|
raise ArgumentError, 'utxos must be array' if utxos.present? && !utxos.is_a?(Array)
|
|
@@ -70,17 +160,54 @@ module MixinBot
|
|
|
70
160
|
signed_raw
|
|
71
161
|
)
|
|
72
162
|
end
|
|
73
|
-
alias create_transfer create_safe_transfer
|
|
74
163
|
|
|
164
|
+
##
|
|
165
|
+
# Builds a UTXO set for a transfer.
|
|
166
|
+
#
|
|
167
|
+
# Selects unspent outputs (UTXOs) from the bot's wallet that sum up
|
|
168
|
+
# to at least the requested amount. This is used internally by
|
|
169
|
+
# create_safe_transfer but can be called directly if needed.
|
|
170
|
+
#
|
|
171
|
+
# The method:
|
|
172
|
+
# - Fetches unspent outputs for the asset
|
|
173
|
+
# - Sorts them by amount (smallest first)
|
|
174
|
+
# - Selects outputs until the amount is reached
|
|
175
|
+
# - Limits to 256 UTXOs maximum
|
|
176
|
+
#
|
|
177
|
+
# @param asset_id [String] the asset UUID
|
|
178
|
+
# @param amount [String, Float, BigDecimal] the amount needed
|
|
179
|
+
# @return [Array<Hash>] array of selected UTXOs
|
|
180
|
+
#
|
|
181
|
+
# @raise [InsufficientBalanceError] if balance is insufficient
|
|
182
|
+
#
|
|
183
|
+
# @example
|
|
184
|
+
# utxos = api.build_utxos(
|
|
185
|
+
# asset_id: '965e5c6e-434c-3fa9-b780-c50f43cd955c',
|
|
186
|
+
# amount: '0.01'
|
|
187
|
+
# )
|
|
188
|
+
# puts "Selected #{utxos.length} UTXOs"
|
|
189
|
+
# puts "Total: #{utxos.sum { |u| u['amount'].to_d }}"
|
|
190
|
+
#
|
|
75
191
|
def build_utxos(asset_id:, amount:)
|
|
192
|
+
amount = amount.to_d
|
|
76
193
|
outputs = safe_outputs(state: 'unspent', asset: asset_id, limit: 500)['data'].sort_by { |o| o['amount'].to_d }
|
|
77
194
|
|
|
78
195
|
utxos = []
|
|
79
196
|
outputs.each do |output|
|
|
80
|
-
break if utxos.
|
|
197
|
+
break if utxos.size >= 256
|
|
81
198
|
|
|
82
|
-
utxos.shift if utxos.size >= 256
|
|
83
199
|
utxos << output
|
|
200
|
+
break if utxos.sum { |o| o['amount'].to_d } >= amount
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
total_in = utxos.sum { |o| o['amount'].to_d }
|
|
204
|
+
if total_in < amount
|
|
205
|
+
raise MixinBot::UtxoInsufficientError.new(
|
|
206
|
+
'insufficient utxo',
|
|
207
|
+
total_input: total_in,
|
|
208
|
+
total_output: amount,
|
|
209
|
+
output_size: utxos.size
|
|
210
|
+
)
|
|
84
211
|
end
|
|
85
212
|
|
|
86
213
|
utxos
|
data/lib/mixin_bot/api/user.rb
CHANGED
|
@@ -2,12 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
module MixinBot
|
|
4
4
|
class API
|
|
5
|
+
##
|
|
6
|
+
# User-related API endpoints: bot/network user lookup, plain network-user
|
|
7
|
+
# creation, and the full Safe Network registration flow.
|
|
8
|
+
#
|
|
9
|
+
# The Safe-network registration mirrors the official Go SDK reference
|
|
10
|
+
# implementation in +RegisterSafeWithSetupPin+ / +RegisterSafeBareUser+:
|
|
11
|
+
# https://github.com/MixinNetwork/bot-api-go-client/blob/master/safe_user.go
|
|
12
|
+
#
|
|
5
13
|
module User
|
|
14
|
+
# Maximum number of times to retry +safe_register+ when the freshly-set
|
|
15
|
+
# TIP PIN has not yet propagated through the Mixin server.
|
|
16
|
+
SAFE_REGISTER_MAX_RETRIES = 3
|
|
17
|
+
|
|
18
|
+
# Base seconds to wait between +safe_register+ retries. The wait grows
|
|
19
|
+
# linearly with the attempt number.
|
|
20
|
+
SAFE_REGISTER_RETRY_BASE_DELAY = 1
|
|
21
|
+
|
|
22
|
+
# Seconds to wait after +update_pin+ before calling +safe_register+, so
|
|
23
|
+
# the new TIP PIN has time to propagate on the server side.
|
|
24
|
+
TIP_PIN_PROPAGATION_DELAY = 1
|
|
25
|
+
|
|
6
26
|
def user(user_id, access_token: nil)
|
|
7
27
|
path = format('/users/%<user_id>s', user_id:)
|
|
8
28
|
client.get path, access_token:
|
|
9
29
|
end
|
|
10
30
|
|
|
31
|
+
##
|
|
32
|
+
# Creates a Mixin network user.
|
|
33
|
+
#
|
|
34
|
+
# When +key+ is omitted a fresh Ed25519 keypair is generated. The
|
|
35
|
+
# response is merged with the hex-encoded session private key under
|
|
36
|
+
# +:private_key+.
|
|
37
|
+
#
|
|
38
|
+
# @param full_name [String] display name for the new user
|
|
39
|
+
# @param key [String, nil] optional 32-byte Ed25519 seed
|
|
40
|
+
# @return [Hash] Mixin response merged with the hex-encoded private key
|
|
41
|
+
#
|
|
11
42
|
def create_user(full_name, key: nil)
|
|
12
43
|
keypair = JOSE::JWA::Ed25519.keypair key
|
|
13
44
|
session_secret = Base64.urlsafe_encode64 keypair[0], padding: false
|
|
@@ -37,54 +68,95 @@ module MixinBot
|
|
|
37
68
|
client.post path, *payload
|
|
38
69
|
end
|
|
39
70
|
|
|
71
|
+
##
|
|
72
|
+
# Creates a Safe-network user end-to-end.
|
|
73
|
+
#
|
|
74
|
+
# Mirrors +RegisterSafeWithSetupPin+ in the Go SDK:
|
|
75
|
+
#
|
|
76
|
+
# 1. generate (or accept) a session keypair and a spend keypair
|
|
77
|
+
# 2. create the network user via {#create_user}
|
|
78
|
+
# 3. set the user's PIN to the TIP public key derived from the spend key
|
|
79
|
+
# 4. wait briefly for propagation, then register on the Safe network
|
|
80
|
+
# (retrying transient failures)
|
|
81
|
+
#
|
|
82
|
+
# The returned keystore is suitable for instantiating a new
|
|
83
|
+
# +MixinBot::API+ that authenticates as the freshly-registered user.
|
|
84
|
+
#
|
|
85
|
+
# @param name [String] display name for the new user
|
|
86
|
+
# @param private_key [String, nil] optional 32-byte session Ed25519 seed
|
|
87
|
+
# @param spend_key [String, nil] optional 32-byte spend Ed25519 seed
|
|
88
|
+
# @return [Hash] keystore with +:app_id+, +:session_id+,
|
|
89
|
+
# +:session_private_key+, +:server_public_key+ and +:spend_key+
|
|
90
|
+
# @raise [MixinBot::Error] when registration ultimately fails. Transient
|
|
91
|
+
# PIN/response errors are retried up to {SAFE_REGISTER_MAX_RETRIES}
|
|
92
|
+
# times; other errors bubble up immediately.
|
|
93
|
+
#
|
|
40
94
|
def create_safe_user(name, private_key: nil, spend_key: nil)
|
|
41
|
-
|
|
42
|
-
private_key = private_keypair[1].unpack1('H*')
|
|
43
|
-
|
|
95
|
+
session_keypair = JOSE::JWA::Ed25519.keypair private_key
|
|
44
96
|
spend_keypair = JOSE::JWA::Ed25519.keypair spend_key
|
|
45
|
-
spend_key = spend_keypair[1].unpack1('H*')
|
|
46
97
|
|
|
47
|
-
|
|
98
|
+
spend_key_hex = spend_keypair[1].unpack1('H*')
|
|
99
|
+
|
|
100
|
+
user = create_user name, key: session_keypair[1][...32]
|
|
101
|
+
data = user.fetch('data')
|
|
48
102
|
|
|
49
103
|
keystore = {
|
|
50
|
-
app_id:
|
|
51
|
-
session_id:
|
|
52
|
-
session_private_key:
|
|
53
|
-
server_public_key:
|
|
54
|
-
spend_key:
|
|
104
|
+
app_id: data['user_id'],
|
|
105
|
+
session_id: data['session_id'],
|
|
106
|
+
session_private_key: session_keypair[1].unpack1('H*'),
|
|
107
|
+
server_public_key: data['pin_token_base64'],
|
|
108
|
+
spend_key: spend_key_hex
|
|
55
109
|
}
|
|
56
|
-
user_api = MixinBot::API.new(**keystore)
|
|
57
110
|
|
|
58
|
-
user_api
|
|
111
|
+
user_api = MixinBot::API.new(**keystore)
|
|
59
112
|
|
|
60
|
-
|
|
61
|
-
|
|
113
|
+
tip_pin = MixinBot.utils.tip_public_key spend_keypair[0], counter: data['tip_counter']
|
|
114
|
+
user_api.update_pin pin: tip_pin
|
|
62
115
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
user_api.safe_register keystore[:spend_key]
|
|
66
|
-
rescue MixinBot::Error => e
|
|
67
|
-
@__retry__ += 1
|
|
68
|
-
raise e if @__retry__ > 3
|
|
116
|
+
# Allow the freshly-set TIP PIN to propagate before registering.
|
|
117
|
+
sleep TIP_PIN_PROPAGATION_DELAY
|
|
69
118
|
|
|
70
|
-
|
|
71
|
-
|
|
119
|
+
with_safe_register_retries do
|
|
120
|
+
user_api.safe_register spend_key_hex
|
|
72
121
|
end
|
|
73
122
|
|
|
74
123
|
keystore
|
|
75
124
|
end
|
|
76
125
|
|
|
77
|
-
|
|
126
|
+
##
|
|
127
|
+
# Registers an existing user on the Safe network.
|
|
128
|
+
#
|
|
129
|
+
# +spend_key+ may be supplied as raw bytes, a hex string, or a
|
|
130
|
+
# Base64-encoded string. It must encode the user's full Ed25519 spend
|
|
131
|
+
# private key (or a 32-byte seed).
|
|
132
|
+
#
|
|
133
|
+
# @param spend_key [String] the user's spend Ed25519 private key
|
|
134
|
+
# @return [Hash] Mixin response
|
|
135
|
+
# @raise [ArgumentError] when +spend_key+ cannot be decoded into at
|
|
136
|
+
# least 32 bytes
|
|
137
|
+
#
|
|
138
|
+
def safe_register(spend_key)
|
|
78
139
|
path = '/safe/users'
|
|
79
140
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
141
|
+
spend_key_bytes = MixinBot.utils.decode_key spend_key
|
|
142
|
+
raise ArgumentError, 'invalid spend_key' if spend_key_bytes.nil? || spend_key_bytes.size < 32
|
|
143
|
+
|
|
144
|
+
keypair = JOSE::JWA::Ed25519.keypair spend_key_bytes[...32]
|
|
145
|
+
public_key = keypair[0].unpack1('H*')
|
|
146
|
+
# Normalize to a 64-byte signing key in hex so that callers may pass a
|
|
147
|
+
# 32-byte seed without crashing the downstream signer.
|
|
148
|
+
signing_key_hex = keypair[1].unpack1('H*')
|
|
83
149
|
|
|
84
|
-
|
|
85
|
-
|
|
150
|
+
# NOTE: the Go SDK's +crypto.Sha256Hash+ is misleadingly named — it
|
|
151
|
+
# actually computes SHA3-256, so +SHA3::Digest::SHA256+ is the correct
|
|
152
|
+
# match. See bot-api-go-client safe_user.go +RegisterSafeBareUser+.
|
|
153
|
+
app_id_hash = SHA3::Digest::SHA256.hexdigest config.app_id
|
|
154
|
+
signature = Base64.urlsafe_encode64(
|
|
155
|
+
JOSE::JWA::Ed25519.sign([app_id_hash].pack('H*'), keypair[1]),
|
|
156
|
+
padding: false
|
|
157
|
+
)
|
|
86
158
|
|
|
87
|
-
pin_base64 = encrypt_tip_pin
|
|
159
|
+
pin_base64 = encrypt_tip_pin signing_key_hex, 'SEQUENCER:REGISTER:', config.app_id, public_key
|
|
88
160
|
|
|
89
161
|
payload = {
|
|
90
162
|
public_key:,
|
|
@@ -95,37 +167,68 @@ module MixinBot
|
|
|
95
167
|
client.post path, **payload
|
|
96
168
|
end
|
|
97
169
|
|
|
170
|
+
##
|
|
171
|
+
# Migrates an existing legacy user to the Safe network.
|
|
172
|
+
#
|
|
173
|
+
# When the user has not yet upgraded to a TIP PIN, +pin+ must be the
|
|
174
|
+
# user's current 6-digit PIN so {#update_pin} can rotate it to a TIP
|
|
175
|
+
# PIN derived from +spend_key+. When the user already has a TIP PIN,
|
|
176
|
+
# +pin+ may be omitted.
|
|
177
|
+
#
|
|
178
|
+
# @param spend_key [String] the user's spend Ed25519 seed or full key
|
|
179
|
+
# @param pin [String, nil] the user's current PIN (only required when
|
|
180
|
+
# the user has not yet upgraded to a TIP PIN)
|
|
181
|
+
# @return [TrueClass, Hash] +true+ if the user already has Safe enabled,
|
|
182
|
+
# otherwise +{ spend_key: <hex> }+
|
|
183
|
+
# @raise [MixinBot::Error] when registration ultimately fails. Transient
|
|
184
|
+
# PIN/response errors are retried up to {SAFE_REGISTER_MAX_RETRIES}
|
|
185
|
+
# times; other errors bubble up immediately.
|
|
186
|
+
#
|
|
98
187
|
def migrate_to_safe(spend_key:, pin: nil)
|
|
99
188
|
profile = me['data']
|
|
100
189
|
return true if profile['has_safe']
|
|
101
190
|
|
|
102
191
|
spend_keypair = JOSE::JWA::Ed25519.keypair spend_key
|
|
103
|
-
|
|
192
|
+
spend_key_hex = spend_keypair[1].unpack1('H*')
|
|
104
193
|
|
|
105
194
|
if profile['tip_key_base64'].blank?
|
|
106
|
-
new_pin = MixinBot.utils.tip_public_key
|
|
107
|
-
update_pin
|
|
195
|
+
new_pin = MixinBot.utils.tip_public_key spend_keypair[0], counter: profile['tip_counter']
|
|
196
|
+
update_pin pin: new_pin, old_pin: pin
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Allow the freshly-set TIP PIN to propagate before registering.
|
|
200
|
+
sleep TIP_PIN_PROPAGATION_DELAY
|
|
108
201
|
|
|
109
|
-
|
|
202
|
+
with_safe_register_retries do
|
|
203
|
+
safe_register spend_key_hex
|
|
110
204
|
end
|
|
111
205
|
|
|
112
|
-
|
|
113
|
-
|
|
206
|
+
{ spend_key: spend_key_hex }.with_indifferent_access
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
private
|
|
114
210
|
|
|
115
|
-
|
|
211
|
+
# Errors that are typically caused by the freshly-set TIP PIN not yet
|
|
212
|
+
# being visible to the Safe-network sequencer. Anything else (auth,
|
|
213
|
+
# missing user, validation, balance, ...) should bubble up immediately.
|
|
214
|
+
RETRIABLE_SAFE_REGISTER_ERRORS = [MixinBot::PinError, MixinBot::ResponseError].freeze
|
|
215
|
+
private_constant :RETRIABLE_SAFE_REGISTER_ERRORS
|
|
216
|
+
|
|
217
|
+
# Yields to the block, retrying transient errors up to
|
|
218
|
+
# {SAFE_REGISTER_MAX_RETRIES} times with linear backoff. The TIP PIN
|
|
219
|
+
# set by {#update_pin} can take a moment to propagate, so the first
|
|
220
|
+
# +safe_register+ attempts often fail.
|
|
221
|
+
def with_safe_register_retries
|
|
222
|
+
attempt = 0
|
|
116
223
|
begin
|
|
117
|
-
|
|
118
|
-
rescue
|
|
119
|
-
|
|
120
|
-
raise
|
|
224
|
+
yield
|
|
225
|
+
rescue *RETRIABLE_SAFE_REGISTER_ERRORS
|
|
226
|
+
attempt += 1
|
|
227
|
+
raise if attempt > SAFE_REGISTER_MAX_RETRIES
|
|
121
228
|
|
|
122
|
-
sleep
|
|
229
|
+
sleep(SAFE_REGISTER_RETRY_BASE_DELAY + attempt)
|
|
123
230
|
retry
|
|
124
231
|
end
|
|
125
|
-
|
|
126
|
-
{
|
|
127
|
-
spend_key:
|
|
128
|
-
}.with_indifferent_access
|
|
129
232
|
end
|
|
130
233
|
end
|
|
131
234
|
end
|