mixin_bot 0.1.0 → 0.3.1

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.
@@ -0,0 +1,335 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ class API
5
+ module Multisig
6
+ # https://w3c.group/c/1574309272319630
7
+
8
+ # {"data":[
9
+ # {
10
+ # "type":"multisig_utxo",
11
+ # "user_id":"514ae2ff-c24e-4379-a482-e2c0f798ebb1",
12
+ # "utxo_id":"94711ac9-5981-4fe3-8c0e-19622219ea72",
13
+ # "asset_id":"965e5c6e-434c-3fa9-b780-c50f43cd955c",
14
+ # "transaction_hash":"2e67f3e36ee4b3c13effcc8a9aaafeb8122cad98f72d9ccc04d65a5ada2aa39d",
15
+ # "output_index":0,
16
+ # "amount":"0.123456",
17
+ # "threshold":2,
18
+ # "members":[
19
+ # "514ae2ff-c24e-4379-a482-e2c0f798ebb1",
20
+ # "13ce6c86-307a-5187-98b0-76424cbc0fbf",
21
+ # "2b9df368-8e3e-46ce-ac57-e6111e8ff50e",
22
+ # "3cb87491-4fa0-4c2f-b387-262b63cbc412"
23
+ # ],
24
+ # "memo":"难道你是女生",
25
+ # "state":"unspent",
26
+ # "created_at":"2019-11-03T13:30:43.922655Z",
27
+ # "signed_by":"",
28
+ # "signed_tx":""
29
+ # }
30
+ # ]}
31
+ def multisigs(limit: 100, offset: nil, access_token: nil)
32
+ path = format('/multisigs?limit=%<limit>s&offset=%<offset>s', limit: limit, offset: offset)
33
+ access_token ||= access_token('GET', path, '')
34
+ authorization = format('Bearer %<access_token>s', access_token: access_token)
35
+ client.get(path, headers: { 'Authorization': authorization })
36
+ end
37
+
38
+ def all_multisigs(utxos: [], offset: nil, access_token: nil)
39
+ res = multisigs(limit: 100, offset: offset, access_token: access_token)
40
+
41
+ return [] if res['data'].nil?
42
+
43
+ utxos += res['data']
44
+
45
+ if res['data'].length < 100
46
+ utxos
47
+ else
48
+ all_multisigs(utxos: utxos, offset: utxos[-1]['created_at'], access_token: access_token)
49
+ end
50
+ end
51
+
52
+ def create_output(receivers:, index:, access_token: nil)
53
+ path = '/outputs'
54
+ payload = {
55
+ receivers: receivers,
56
+ index: index
57
+ }
58
+ access_token ||= access_token('POST', path, payload.to_json)
59
+ authorization = format('Bearer %<access_token>s', access_token: access_token)
60
+ client.post(path, headers: { 'Authorization': authorization }, json: payload)
61
+ end
62
+
63
+ # transfer from the multisig address
64
+ # create a request for multi sign
65
+ # for now, raw(RAW-TRANSACTION-HEX) can only be generated by Mixin SDK of Golang or Javascript
66
+ def create_sign_multisig_request(raw, access_token: nil)
67
+ path = '/multisigs'
68
+ payload = {
69
+ action: 'sign',
70
+ raw: raw
71
+ }
72
+ access_token ||= access_token('POST', path, payload.to_json)
73
+ authorization = format('Bearer %<access_token>s', access_token: access_token)
74
+ client.post(path, headers: { 'Authorization': authorization }, json: payload)
75
+ end
76
+
77
+ # transfer from the multisig address
78
+ # create a request for unlock a multi-sign
79
+ def create_unlock_multisig_request(raw, access_token: nil)
80
+ path = '/multisigs'
81
+ payload = {
82
+ action: 'unlock',
83
+ raw: raw
84
+ }
85
+ access_token ||= access_token('POST', path, payload.to_json)
86
+ authorization = format('Bearer %<access_token>s', access_token: access_token)
87
+ client.post(path, headers: { 'Authorization': authorization }, json: payload)
88
+ end
89
+
90
+ def sign_multisig_request(request_id, pin)
91
+ path = format('/multisigs/%<request_id>s/sign', request_id: request_id)
92
+ payload = {
93
+ pin: encrypt_pin(pin)
94
+ }
95
+ access_token ||= access_token('POST', path, payload.to_json)
96
+ authorization = format('Bearer %<access_token>s', access_token: access_token)
97
+ client.post(path, headers: { 'Authorization': authorization }, json: payload)
98
+ end
99
+
100
+ def unlock_multisig_request(request_id, pin)
101
+ path = format('/multisigs/%<request_id>s/unlock', request_id: request_id)
102
+ payload = {
103
+ pin: encrypt_pin(pin)
104
+ }
105
+ access_token ||= access_token('POST', path, payload.to_json)
106
+ authorization = format('Bearer %<access_token>s', access_token: access_token)
107
+ client.post(path, headers: { 'Authorization': authorization }, json: payload)
108
+ end
109
+
110
+ def cancel_multisig_request(request_id, pin)
111
+ path = format('/multisigs/%<request_id>s/cancel', request_id: request_id)
112
+ payload = {
113
+ pin: encrypt_pin(pin)
114
+ }
115
+ access_token ||= access_token('POST', path, payload.to_json)
116
+ authorization = format('Bearer %<access_token>s', access_token: access_token)
117
+ client.post(path, headers: { 'Authorization': authorization }, json: payload)
118
+ end
119
+
120
+ # pay to the multisig address
121
+ # used for create multisig payment code_id
122
+ def create_multisig_payment(params)
123
+ path = '/payments'
124
+ payload = {
125
+ asset_id: params[:asset_id],
126
+ amount: params[:amount].to_s,
127
+ trace_id: params[:trace_id] || SecureRandom.uuid,
128
+ memo: params[:memo],
129
+ opponent_multisig: {
130
+ receivers: params[:receivers],
131
+ threshold: params[:threshold]
132
+ }
133
+ }
134
+ access_token = params[:access_token]
135
+ access_token ||= access_token('POST', path, payload.to_json)
136
+ authorization = format('Bearer %<access_token>s', access_token: access_token)
137
+ client.post(path, headers: { 'Authorization': authorization }, json: payload)
138
+ end
139
+
140
+ def verify_multisig(code_id, access_token: nil)
141
+ path = format('/codes/%<code_id>s', code_id: code_id)
142
+ access_token ||= access_token('GET', path, '')
143
+ authorization = format('Bearer %<access_token>s', access_token: access_token)
144
+ client.get(path, headers: { 'Authorization': authorization })
145
+ end
146
+
147
+ # send a signed transaction to main net
148
+ def send_raw_transaction(raw, access_token: nil)
149
+ path = '/external/proxy'
150
+ payload = {
151
+ method: 'sendrawtransaction',
152
+ params: [raw]
153
+ }
154
+
155
+ access_token ||= access_token('POST', path, payload.to_json)
156
+ authorization = format('Bearer %<access_token>s', access_token: access_token)
157
+ client.post(path, headers: { 'Authorization': authorization }, json: payload)
158
+ end
159
+
160
+ def build_threshold_script(threshold)
161
+ s = threshold.to_s(16)
162
+ s = s.length == 1 ? "0#{s}" : s
163
+ raise 'NVALID THRESHOLD' if s.length > 2
164
+
165
+ "fffe#{s}"
166
+ end
167
+
168
+ # filter utxo by members, asset_id and threshold
169
+ def filter_utxos(params)
170
+ utxos = all_multisigs(access_token: params[:access_token])
171
+
172
+ unless params[:members].nil?
173
+ utxos = utxos.filter(
174
+ &lambda { |utxo|
175
+ utxo['members'].sort == params[:members].sort
176
+ }
177
+ )
178
+ end
179
+
180
+ unless params[:asset_id].nil?
181
+ utxos = utxos.filter(
182
+ &lambda { |utxo|
183
+ utxo['asset_id'] == params[:asset_id]
184
+ }
185
+ )
186
+ end
187
+
188
+ unless params[:threshold].nil?
189
+ utxos = utxos.filter(
190
+ &lambda { |utxo|
191
+ utxo['threshold'] == params[:threshold]
192
+ }
193
+ )
194
+ end
195
+
196
+ unless params[:state].nil?
197
+ utxos = utxos.filter(
198
+ &lambda { |utxo|
199
+ utxo['state'] == params[:state]
200
+ }
201
+ )
202
+ end
203
+
204
+ utxos
205
+ end
206
+
207
+ # params:
208
+ # {
209
+ # senders: [ uuid ],
210
+ # receivers: [ uuid ],
211
+ # threshold: integer,
212
+ # asset_id: uuid,
213
+ # asset_mixin_id: string,
214
+ # amount: string / float,
215
+ # memo: string,
216
+ # }
217
+ def build_raw_transaction(params)
218
+ senders = params[:senders]
219
+ receivers = params[:receivers]
220
+ asset_id = params[:asset_id]
221
+ asset_mixin_id = params[:asset_mixin_id]
222
+ amount = params[:amount]
223
+ memo = params[:memo]
224
+ threshold = params[:threshold]
225
+ access_token = params[:access_token]
226
+ utxos = params[:utxos]
227
+
228
+ raise 'access_token required!' if access_token.nil? && !senders.include?(client_id)
229
+
230
+ # default to use all unspent utxo
231
+ utxos ||= filter_utxos(
232
+ members: senders,
233
+ asset_id: asset_id,
234
+ threshold: threshold,
235
+ state: 'unspent',
236
+ access_token: access_token
237
+ )
238
+ amount = amount.to_f.round(8)
239
+ input_amount = utxos.map(
240
+ &lambda { |utxo|
241
+ utxo['amount'].to_f
242
+ }
243
+ ).sum.round(8)
244
+
245
+ if input_amount < amount
246
+ raise format(
247
+ 'not enough amount! %<input_amount>s < %<amount>s',
248
+ input_amount: input_amount,
249
+ amount: amount
250
+ )
251
+ end
252
+
253
+ inputs = utxos.map(
254
+ &lambda { |utx|
255
+ {
256
+ 'hash' => utx['transaction_hash'],
257
+ 'index' => utx['output_index']
258
+ }
259
+ }
260
+ )
261
+
262
+ outputs = []
263
+ output0 = create_output(receivers: receivers, index: 0)['data']
264
+ output0['amount'] = format('%<amount>.8f', amount: amount)
265
+ output0['script'] = build_threshold_script(receivers.length)
266
+ outputs << output0
267
+
268
+ if input_amount > amount
269
+ output1 = create_output(receivers: senders, index: 1)['data']
270
+ output1['amount'] = format('%<amount>.8f', amount: input_amount - amount)
271
+ output1['script'] = build_threshold_script(utxos[0]['threshold'].to_i)
272
+ outputs << output1
273
+ end
274
+
275
+ extra = Digest.hexencode memo.to_s.slice(0, 140)
276
+ tx = {
277
+ version: 1,
278
+ asset: asset_mixin_id,
279
+ inputs: inputs,
280
+ outputs: outputs,
281
+ extra: extra
282
+ }
283
+
284
+ build_transaction tx.to_json
285
+ end
286
+
287
+ def str_to_bin(str)
288
+ return if str.nil?
289
+
290
+ str.scan(/../).map(&:hex).pack('c*')
291
+ end
292
+
293
+ def build_inputs(inputs)
294
+ res = []
295
+ prototype = {
296
+ 'Hash' => nil,
297
+ 'Index' => nil,
298
+ 'Genesis' => nil,
299
+ 'Deposit' => nil,
300
+ 'Mint' => nil
301
+ }
302
+ inputs.each do |input|
303
+ struc = prototype.dup
304
+ struc['Hash'] = str_to_bin input['hash']
305
+ struc['Index'] = input['index']
306
+ res << struc
307
+ end
308
+
309
+ res
310
+ end
311
+
312
+ def build_outputs(outputs)
313
+ res = []
314
+ prototype = {
315
+ 'Type' => 0,
316
+ 'Amount' => nil,
317
+ 'Keys' => nil,
318
+ 'Script' => nil,
319
+ 'Mask' => nil
320
+ }
321
+ outputs.each do |output|
322
+ struc = prototype.dup
323
+ struc['Type'] = str_to_bin output['type']
324
+ struc['Amount'] = str_to_bin output['amount']
325
+ struc['Keys'] = output['keys'].map(&->(key) { str_to_bin(key) })
326
+ struc['Script'] = str_to_bin output['script']
327
+ struc['Mask'] = str_to_bin output['mask']
328
+ res << struc
329
+ end
330
+
331
+ res
332
+ end
333
+ end
334
+ end
335
+ end
@@ -15,14 +15,14 @@ module MixinBot
15
15
  client.post(path, headers: { 'Authorization': authorization }, json: payload)
16
16
  end
17
17
 
18
- # not verified yet
19
18
  # https://developers.mixin.one/api/alpha-mixin-network/create-pin/
20
- def update_pin(old_pin:, new_pin:)
19
+ def update_pin(old_pin:, pin:)
21
20
  path = '/pin/update'
22
- timestamp = Time.now.utc.to_i
21
+ encrypted_old_pin = old_pin.nil? ? '' : encrypt_pin(old_pin, iterator: Time.now.utc.to_i)
22
+ encrypted_pin = encrypt_pin(pin, iterator: Time.now.utc.to_i + 1)
23
23
  payload = {
24
- old_pin: old_pin.nil? ? '' : encrypt_pin(old_pin, timestamp: timestamp),
25
- pin: encrypt_pin(new_pin, timestamp: timestamp)
24
+ old_pin: encrypted_old_pin,
25
+ pin: encrypted_pin
26
26
  }
27
27
 
28
28
  access_token = access_token('POST', path, payload.to_json)
@@ -35,26 +35,24 @@ module MixinBot
35
35
  msg = Base64.strict_decode64 msg
36
36
  iv = msg[0..15]
37
37
  cipher = msg[16..47]
38
- aes_key = JOSE::JWA::PKCS1.rsaes_oaep_decrypt('SHA256', pin_token, private_key, session_id)
39
38
  alg = 'AES-256-CBC'
40
39
  decode_cipher = OpenSSL::Cipher.new(alg)
41
40
  decode_cipher.decrypt
42
41
  decode_cipher.iv = iv
43
- decode_cipher.key = aes_key
42
+ decode_cipher.key = _generate_aes_key
44
43
  decoded = decode_cipher.update(cipher)
45
44
  decoded[0..5]
46
45
  end
47
46
 
48
47
  # https://developers.mixin.one/api/alpha-mixin-network/encrypted-pin/
49
48
  # use timestamp(timestamp) for iterator as default: must be bigger than the previous, the first time must be greater than 0. After a new session created, it will be reset to 0.
50
- def encrypt_pin(pin_code, timestamp: nil)
51
- aes_key = JOSE::JWA::PKCS1.rsaes_oaep_decrypt('SHA256', pin_token, private_key, session_id)
52
- timestamp ||= Time.now.utc.to_i
53
- tszero = timestamp % 0x100
54
- tsone = (timestamp % 0x10000) >> 8
55
- tstwo = (timestamp % 0x1000000) >> 16
56
- tsthree = (timestamp % 0x100000000) >> 24
57
- tsstring = tszero.chr + tsone.chr + tstwo.chr + tsthree.chr + "\0\0\0\0"
49
+ def encrypt_pin(pin_code, iterator: nil)
50
+ iterator ||= Time.now.utc.to_i
51
+ tszero = iterator % 0x100
52
+ tsone = (iterator % 0x10000) >> 8
53
+ tstwo = (iterator % 0x1000000) >> 16
54
+ tsthree = (iterator % 0x100000000) >> 24
55
+ tsstring = "#{tszero.chr}#{tsone.chr}#{tstwo.chr}#{tsthree.chr}\u0000\u0000\u0000\u0000"
58
56
  encrypt_content = pin_code + tsstring + tsstring
59
57
  pad_count = 16 - encrypt_content.length % 16
60
58
  padded_content =
@@ -68,12 +66,28 @@ module MixinBot
68
66
  aes = OpenSSL::Cipher.new(alg)
69
67
  iv = OpenSSL::Cipher.new(alg).random_iv
70
68
  aes.encrypt
71
- aes.key = aes_key
69
+ aes.key = _generate_aes_key
72
70
  aes.iv = iv
73
71
  cipher = aes.update(padded_content)
74
72
  msg = iv + cipher
75
73
  Base64.strict_encode64 msg
76
74
  end
77
75
  end
76
+
77
+ def _generate_aes_key
78
+ if pin_token.size == 32
79
+ JOSE::JWA::X25519.x25519(
80
+ JOSE::JWA::Ed25519.secret_to_curve25519(private_key[0..31]),
81
+ pin_token
82
+ )
83
+ else
84
+ JOSE::JWA::PKCS1.rsaes_oaep_decrypt(
85
+ 'SHA256',
86
+ pin_token,
87
+ OpenSSL::PKey::RSA.new(private_key),
88
+ session_id
89
+ )
90
+ end
91
+ end
78
92
  end
79
93
  end
@@ -3,7 +3,7 @@
3
3
  module MixinBot
4
4
  class API
5
5
  module Snapshot
6
- def read_snapshots(options = {})
6
+ def read_network_snapshots(options = {})
7
7
  path = format(
8
8
  '/network/snapshots?limit=%<limit>s&offset=%<offset>s&asset=%<asset>s&order=%<order>s',
9
9
  limit: options[:limit],
@@ -12,21 +12,30 @@ module MixinBot
12
12
  order: options[:order]
13
13
  )
14
14
 
15
- if options[:private]
16
- # TODO:
17
- # read private snapshots
18
- access_token = access_token('GET', path)
19
- authorization = format('Bearer %<access_token>s', access_token: access_token)
20
- client.get(path, headers: { 'Authorization': authorization, 'Content-length': 0 })
21
- else
22
- # read public snapshots as default
23
- client.get(path)
24
- end
15
+ access_token = options[:access_token] || access_token('GET', path)
16
+ authorization = format('Bearer %<access_token>s', access_token: access_token)
17
+ client.get(path, headers: { 'Authorization': authorization })
18
+ end
19
+
20
+ def read_snapshots(options = {})
21
+ path = format(
22
+ '/snapshots?limit=%<limit>s&offset=%<offset>s&asset=%<asset>s',
23
+ limit: options[:limit],
24
+ offset: options[:offset],
25
+ asset: options[:asset]
26
+ )
27
+
28
+ access_token = options[:access_token] || access_token('GET', path)
29
+ authorization = format('Bearer %<access_token>s', access_token: access_token)
30
+ client.get(path, headers: { 'Authorization': authorization })
25
31
  end
26
32
 
27
- def read_snapshot(snapshot_id)
28
- path = format('network/snapshots/%<snapshot_id>s', snapshot_id: snapshot_id)
29
- client.get(path)
33
+ def read_network_snapshot(snapshot_id, options = {})
34
+ path = format('/network/snapshots/%<snapshot_id>s', snapshot_id: snapshot_id)
35
+
36
+ access_token = options[:access_token] || access_token('GET', path)
37
+ authorization = format('Bearer %<access_token>s', access_token: access_token)
38
+ client.get(path, headers: { 'Authorization': authorization })
30
39
  end
31
40
  end
32
41
  end