mixin_bot 0.12.0 → 1.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/lib/mixin_bot/api/address.rb +21 -0
  3. data/lib/mixin_bot/api/app.rb +5 -11
  4. data/lib/mixin_bot/api/asset.rb +9 -16
  5. data/lib/mixin_bot/api/attachment.rb +27 -22
  6. data/lib/mixin_bot/api/auth.rb +31 -53
  7. data/lib/mixin_bot/api/blaze.rb +4 -3
  8. data/lib/mixin_bot/api/collectible.rb +60 -58
  9. data/lib/mixin_bot/api/conversation.rb +29 -49
  10. data/lib/mixin_bot/api/encrypted_message.rb +17 -17
  11. data/lib/mixin_bot/api/legacy_multisig.rb +87 -0
  12. data/lib/mixin_bot/api/legacy_output.rb +50 -0
  13. data/lib/mixin_bot/api/legacy_payment.rb +31 -0
  14. data/lib/mixin_bot/api/legacy_snapshot.rb +39 -0
  15. data/lib/mixin_bot/api/legacy_transaction.rb +173 -0
  16. data/lib/mixin_bot/api/legacy_transfer.rb +42 -0
  17. data/lib/mixin_bot/api/me.rb +13 -17
  18. data/lib/mixin_bot/api/message.rb +13 -10
  19. data/lib/mixin_bot/api/multisig.rb +16 -221
  20. data/lib/mixin_bot/api/output.rb +46 -0
  21. data/lib/mixin_bot/api/payment.rb +9 -20
  22. data/lib/mixin_bot/api/pin.rb +57 -65
  23. data/lib/mixin_bot/api/rpc.rb +9 -11
  24. data/lib/mixin_bot/api/snapshot.rb +15 -29
  25. data/lib/mixin_bot/api/tip.rb +43 -0
  26. data/lib/mixin_bot/api/transaction.rb +184 -60
  27. data/lib/mixin_bot/api/transfer.rb +64 -32
  28. data/lib/mixin_bot/api/user.rb +83 -53
  29. data/lib/mixin_bot/api/withdraw.rb +52 -53
  30. data/lib/mixin_bot/api.rb +78 -45
  31. data/lib/mixin_bot/cli/api.rb +151 -8
  32. data/lib/mixin_bot/cli/utils.rb +14 -4
  33. data/lib/mixin_bot/cli.rb +13 -10
  34. data/lib/mixin_bot/client.rb +76 -127
  35. data/lib/mixin_bot/configuration.rb +98 -0
  36. data/lib/mixin_bot/nfo.rb +174 -0
  37. data/lib/mixin_bot/transaction.rb +505 -0
  38. data/lib/mixin_bot/utils/address.rb +108 -0
  39. data/lib/mixin_bot/utils/crypto.rb +182 -0
  40. data/lib/mixin_bot/utils/decoder.rb +58 -0
  41. data/lib/mixin_bot/utils/encoder.rb +63 -0
  42. data/lib/mixin_bot/utils.rb +8 -109
  43. data/lib/mixin_bot/uuid.rb +41 -0
  44. data/lib/mixin_bot/version.rb +1 -1
  45. data/lib/mixin_bot.rb +39 -14
  46. data/lib/mvm/bridge.rb +2 -19
  47. data/lib/mvm/client.rb +11 -33
  48. data/lib/mvm/nft.rb +4 -4
  49. data/lib/mvm/registry.rb +9 -9
  50. data/lib/mvm/scan.rb +3 -5
  51. data/lib/mvm.rb +5 -6
  52. metadata +101 -44
  53. data/lib/mixin_bot/utils/nfo.rb +0 -176
  54. data/lib/mixin_bot/utils/transaction.rb +0 -478
  55. data/lib/mixin_bot/utils/uuid.rb +0 -43
@@ -4,153 +4,102 @@ module MixinBot
4
4
  class Client
5
5
  SERVER_SCHEME = 'https'
6
6
 
7
- attr_reader :host
8
-
9
- def initialize(host = 'api.mixin.one')
10
- @host = host
7
+ attr_reader :config, :conn
8
+
9
+ def initialize(config)
10
+ @config = config || MixinBot.config
11
+ @conn = Faraday.new(
12
+ url: "#{SERVER_SCHEME}://#{config.api_host}",
13
+ headers: {
14
+ 'Content-Type' => 'application/json',
15
+ 'User-Agent' => "mixin_bot/#{MixinBot::VERSION}"
16
+ }
17
+ ) do |f|
18
+ f.request :json
19
+ f.request :retry
20
+ f.response :json
21
+ f.response :logger if config.debug
22
+ end
11
23
  end
12
24
 
13
- def get(path, options = {})
14
- request(:get, path, options)
25
+ def get(path, *, **)
26
+ request(:get, path, *, **)
15
27
  end
16
28
 
17
- def post(path, options = {})
18
- request(:post, path, options)
29
+ def post(path, *, **)
30
+ request(:post, path, *, **)
19
31
  end
20
32
 
21
33
  private
22
34
 
23
- def request(verb, path, options = {})
24
- uri = uri_for path
25
-
26
- options[:headers] ||= {}
27
- options[:headers]['Content-Type'] ||= 'application/json'
28
-
29
- begin
30
- response = HTTP.timeout(connect: 5, write: 5, read: 5).request(verb, uri, options)
31
- rescue HTTP::Error => e
32
- raise HttpError, e.message
33
- end
34
-
35
- raise RequestError, response.to_s unless response.status.success?
36
-
37
- parse_response(response) do |parse_as, result|
38
- case parse_as
39
- when :json
40
- if result['error'].nil?
41
- result.merge! result['data'] if result['data'].is_a? Hash
42
- break result
43
- end
35
+ def request(verb, path, *args, **kwargs)
36
+ access_token = kwargs.delete :access_token
37
+ exp_in = kwargs.delete(:exp_in) || 600
38
+ scp = kwargs.delete(:scp) || 'FULL'
44
39
 
45
- errmsg = "errcode: #{result['error']['code']}, errmsg: #{result['error']['description']}, request_id: #{response&.[]('X-Request-Id')}, server_time: #{response&.[]('X-Server-Time')}'"
46
-
47
- # status code description
48
- # 202 400 The request body can’t be pasred as valid data.
49
- # 202 401 Unauthorized.
50
- # 202 403 Forbidden.
51
- # 202 404 The endpoint is not found.
52
- # 202 429 Too Many Requests.
53
- # 202 10006 App update required.
54
- # 202 20116 The group chat is full.
55
- # 500 500 Internal Server Error.
56
- # 500 7000 Blaze server error.
57
- # 500 7001 The blaze operation timeout.
58
- # 202 10002 Illegal request paramters.
59
- # 202 20117 Insufficient balance。
60
- # 202 20118 PIN format error.
61
- # 202 20119 PIN error.
62
- # 202 20120 Transfer amount is too small.
63
- # 202 20121 Authorization code has expired.
64
- # 202 20124 Insufficient withdrawal fee.
65
- # 202 20125 The transfer has been paid by someone else.
66
- # 202 20127 The withdrawal amount is too small.
67
- # 202 20131 Withdrawal Memo format error.
68
- # 500 30100 The current asset's public chain synchronization error.
69
- # 500 30101 Wrong private key.
70
- # 500 30102 Wrong withdrawal address.
71
- # 500 30103 Insufficient pool.
72
- # 500 7000 WebSocket server error.
73
- # 500 7001 WebSocket operation timeout.
74
- case result['error']['code']
75
- when 401, 20121
76
- raise UnauthorizedError, errmsg
77
- when 403, 20116, 10002, 429
78
- raise ForbiddenError, errmsg
79
- when 404
80
- raise NotFoundError, errmsg
81
- when 400, 10006, 20133, 500, 7000, 7001
82
- raise ResponseError, errmsg
83
- when 20117
84
- raise InsufficientBalanceError, errmsg
85
- when 20118, 20119
86
- raise PinError, errmsg
87
- when 30103
88
- raise InsufficientPoolError, errmsg
40
+ kwargs.compact!
41
+ body =
42
+ if verb == :post
43
+ if args.present?
44
+ args.to_json
89
45
  else
90
- raise ResponseError, errmsg
46
+ kwargs.to_json
91
47
  end
92
48
  else
93
- result
49
+ ''
94
50
  end
95
- end
96
- end
97
-
98
- def uri_for(path)
99
- uri_options = {
100
- scheme: SERVER_SCHEME,
101
- host: host,
102
- path: path
103
- }
104
- Addressable::URI.new(uri_options)
105
- end
106
51
 
107
- def parse_response(response)
108
- content_type = response.headers[:content_type]
109
- parse_as = {
110
- %r{^application/json} => :json,
111
- %r{^image/.*} => :file,
112
- %r{^text/html} => :xml,
113
- %r{^text/plain} => :plain
114
- }.each_with_object([]) { |match, memo| memo << match[1] if content_type =~ match[0] }.first || :plain
52
+ path = "#{path}?#{URI.encode_www_form(kwargs.sort_by { |k, _v| k })}" if verb == :get && kwargs.present?
53
+ access_token ||=
54
+ MixinBot.utils.access_token(
55
+ verb.to_s.upcase,
56
+ path,
57
+ body,
58
+ exp_in:,
59
+ scp:,
60
+ app_id: config.app_id,
61
+ session_id: config.session_id,
62
+ private_key: config.session_private_key
63
+ )
64
+ authorization = format('Bearer %<access_token>s', access_token:)
65
+
66
+ response =
67
+ case verb
68
+ when :get
69
+ @conn.get path, nil, { Authorization: authorization }
70
+ when :post
71
+ @conn.post path, body, { Authorization: authorization }
72
+ end
115
73
 
116
- if parse_as == :plain
117
- result = JSON.parse(response&.body&.to_s)
118
- result && yield(:json, result)
74
+ result = response.body
119
75
 
120
- yield(:plain, response.body)
76
+ if result['error'].blank?
77
+ result.merge! result['data'] if result['data'].is_a? Hash
78
+ return result
121
79
  end
122
80
 
123
- case parse_as
124
- when :json
125
- result = JSON.parse(response.body.to_s)
126
- when :file
127
- extension =
128
- if response.headers[:content_type] =~ %r{^image/.*}
129
- {
130
- 'image/gif': '.gif',
131
- 'image/jpeg': '.jpg',
132
- 'image/png': '.png'
133
- }[response.headers['content-type']]
134
- else
135
- ''
136
- end
137
-
138
- begin
139
- file = Tempfile.new(['mixin-file-', extension])
140
- file.binmode
141
- file.write(response.body)
142
- ensure
143
- file&.close
144
- end
145
-
146
- result = file
147
- when :xml
148
- result = Hash.from_xml(response.body.to_s)
81
+ errmsg = "#{verb.upcase}|#{path}|#{body}, errcode: #{result['error']['code']}, errmsg: #{result['error']['description']}, request_id: #{response&.[]('X-Request-Id')}, server_time: #{response&.[]('X-Server-Time')}'"
82
+
83
+ case result['error']['code']
84
+ when 401, 20121
85
+ raise UnauthorizedError, errmsg
86
+ when 403, 20116, 10002, 429
87
+ raise ForbiddenError, errmsg
88
+ when 404
89
+ raise NotFoundError, errmsg
90
+ when 400, 10006, 20133, 500, 7000, 7001
91
+ raise ResponseError, errmsg
92
+ when 20117
93
+ raise InsufficientBalanceError, errmsg
94
+ when 20118, 20119
95
+ raise PinError, errmsg
96
+ when 30103
97
+ raise InsufficientPoolError, errmsg
98
+ when 10404
99
+ raise UserNotFoundError, errmsg
149
100
  else
150
- result = response.body
101
+ raise ResponseError, errmsg
151
102
  end
152
-
153
- yield(parse_as, result)
154
103
  end
155
104
  end
156
105
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ class Configuration
5
+ CONFIGURABLE_ATTRS = %i[
6
+ app_id
7
+ client_secret
8
+ session_id
9
+ session_private_key
10
+ server_public_key
11
+ spend_key
12
+ pin
13
+ api_host
14
+ blaze_host
15
+ session_private_key_curve25519
16
+ server_public_key_curve25519
17
+ debug
18
+ ].freeze
19
+ attr_accessor(*CONFIGURABLE_ATTRS)
20
+
21
+ def initialize(**kwargs)
22
+ @app_id = kwargs[:app_id] || kwargs[:client_id]
23
+ @client_secret = kwargs[:client_secret]
24
+ @session_id = kwargs[:session_id]
25
+ @api_host = kwargs[:api_host] || 'api.mixin.one'
26
+ @blaze_host = kwargs[:blaze_host] || 'blaze.mixin.one'
27
+ @debug = kwargs[:debug] || false
28
+
29
+ self.session_private_key = kwargs[:session_private_key] || kwargs[:private_key]
30
+ self.server_public_key = kwargs[:server_public_key] || kwargs[:pin_token]
31
+ self.spend_key = kwargs[:spend_key]
32
+ self.pin = kwargs[:pin] || spend_key
33
+ end
34
+
35
+ def valid?
36
+ %i[app_id session_id session_private_key server_public_key].all? do |attr|
37
+ send(attr).present?
38
+ end
39
+ end
40
+
41
+ def session_private_key=(key)
42
+ return if key.blank?
43
+
44
+ _private_key = decode_key key
45
+ @session_private_key =
46
+ if _private_key.size == 32
47
+ JOSE::JWA::Ed25519.keypair(_private_key).last
48
+ else
49
+ _private_key
50
+ end
51
+
52
+ @session_private_key_curve25519 = JOSE::JWA::Ed25519.sk_to_curve25519(@session_private_key) if @session_private_key.size == 64
53
+ end
54
+
55
+ def server_public_key=(key)
56
+ return if key.blank?
57
+
58
+ @server_public_key = decode_key key
59
+ # HEX encoded
60
+ @server_public_key_curve25519 =
61
+ if key.match?(/\A[\h]{32,}\z/i)
62
+ JOSE::JWA::Ed25519.pk_to_curve25519 @server_public_key
63
+ else
64
+ server_public_key
65
+ end
66
+ end
67
+
68
+ def spend_key=(key)
69
+ return if key.blank?
70
+
71
+ _private_key = decode_key key
72
+ @spend_key =
73
+ if _private_key.size == 32
74
+ JOSE::JWA::Ed25519.keypair(_private_key).last
75
+ else
76
+ _private_key
77
+ end
78
+ end
79
+
80
+ def pin=(key)
81
+ return if key.blank?
82
+
83
+ _private_key = decode_key key
84
+ @pin =
85
+ if _private_key.size == 32
86
+ JOSE::JWA::Ed25519.keypair(_private_key).last
87
+ else
88
+ _private_key
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def decode_key(key)
95
+ MixinBot.utils.decode_key key
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ class Nfo
5
+ NFT_MEMO_PREFIX = 'NFO'
6
+ NFT_MEMO_VERSION = 0x00
7
+ NFT_MEMO_DEFAULT_CHAIN = '43d61dcd-e413-450d-80b8-101d5e903357'
8
+ NFT_MEMO_DEFAULT_CLASS = '3c8c161a18ae2c8b14fda1216fff7da88c419b5d'
9
+ NULL_UUID = '00000000-0000-0000-0000-000000000000'
10
+
11
+ attr_reader :prefix, :version, :raw
12
+ attr_accessor :mask, :chain, :nm_class, :collection, :token, :extra, :memo, :hex
13
+
14
+ def initialize(**kwargs)
15
+ @prefix = NFT_MEMO_PREFIX
16
+ @version = NFT_MEMO_VERSION
17
+ @mask = kwargs[:mask] || 0
18
+ @chain = kwargs[:chain] || NFT_MEMO_DEFAULT_CHAIN
19
+ @nm_class = kwargs[:nm_class] || NFT_MEMO_DEFAULT_CLASS
20
+ @collection = kwargs[:collection] || NULL_UUID
21
+ @token = kwargs[:token].presence&.to_i
22
+ @extra = kwargs[:extra]
23
+ @memo = kwargs[:memo]
24
+ @hex = kwargs[:hex]
25
+ end
26
+
27
+ def mint_memo
28
+ raise MixinBot::InvalidNfoFormatError, 'token is required' if token.blank?
29
+ raise MixinBot::InvalidNfoFormatError, 'extra must be 256-bit string' if extra.blank? || extra.size != 64
30
+
31
+ @collection = NULL_UUID if collection.blank?
32
+ @chain = NFT_MEMO_DEFAULT_CHAIN
33
+ @nm_class = NFT_MEMO_DEFAULT_CLASS
34
+ mark 0
35
+ encode
36
+
37
+ memo
38
+ end
39
+
40
+ def unique_token_id
41
+ bytes = []
42
+ bytes += MixinBot::UUID.new(hex: chain).packed.bytes
43
+ bytes += [nm_class].pack('H*').bytes
44
+ bytes += MixinBot::UUID.new(hex: collection).packed.bytes
45
+ bytes += MixinBot.utils.encode_int token
46
+
47
+ md5 = Digest::MD5.new
48
+ md5.update bytes.pack('c*')
49
+ digest = [md5.hexdigest].pack('H*').bytes
50
+
51
+ digest[6] = (digest[6] & 0x0f) | 0x30
52
+ digest[8] = (digest[8] & 0x3f) | 0x80
53
+
54
+ hex = digest.pack('c*').unpack1('H*')
55
+
56
+ MixinBot::UUID.new(hex:).unpacked
57
+ end
58
+
59
+ def mark(*indexes)
60
+ indexes.map do |index|
61
+ raise ArgumentError, "invalid NFO memo index #{index}" if index >= 64 || index.negative?
62
+
63
+ @mask = mask ^ (1 << index)
64
+ end
65
+ end
66
+
67
+ def encode
68
+ bytes = []
69
+
70
+ bytes += prefix.bytes
71
+ bytes += [version]
72
+
73
+ if mask.zero?
74
+ bytes += [0]
75
+ else
76
+ bytes += [1]
77
+ bytes += MixinBot.utils.encode_uint_64 mask
78
+ bytes += MixinBot::UUID.new(hex: chain).packed.bytes
79
+
80
+ class_bytes = [nm_class].pack('H*').bytes
81
+ bytes += MixinBot.utils.encode_int class_bytes.size
82
+ bytes += class_bytes
83
+
84
+ collection_bytes = collection.split('-').pack('H* H* H* H* H*').bytes
85
+ bytes += MixinBot.utils.encode_int collection_bytes.size
86
+ bytes += collection_bytes
87
+
88
+ # token_bytes = memo[:token].split('-').pack('H* H* H* H* H*').bytes
89
+ token_bytes = MixinBot.utils.encode_int token
90
+ bytes += MixinBot.utils.encode_int token_bytes.size
91
+ bytes += token_bytes
92
+ end
93
+
94
+ extra_bytes = [extra].pack('H*').bytes
95
+ bytes += MixinBot.utils.encode_int extra_bytes.size
96
+ bytes += extra_bytes
97
+
98
+ @raw = bytes.pack('C*')
99
+ @hex = raw.unpack1('H*')
100
+ @memo = Base64.urlsafe_encode64 raw, padding: false
101
+
102
+ self
103
+ end
104
+
105
+ def decode
106
+ @raw =
107
+ if memo.present?
108
+ Base64.urlsafe_decode64 memo
109
+ elsif hex.present?
110
+ [hex].pack('H*')
111
+ else
112
+ raise InvalidNfoFormatError, 'memo or hex is required'
113
+ end
114
+
115
+ @hex = raw.unpack1('H*') if hex.blank?
116
+ @memo = Base64.urlsafe_encode64 raw, padding: false if memo.blank?
117
+
118
+ decode_bytes
119
+ self
120
+ end
121
+
122
+ def decode_bytes
123
+ bytes = raw.bytes
124
+
125
+ _prefix = bytes.shift(3).pack('C*')
126
+ raise MixinBot::InvalidNfoFormatError, "NFO prefix #{_prefix}" if _prefix != prefix
127
+
128
+ _version = bytes.shift
129
+ raise MixinBot::InvalidNfoFormatError, "NFO version #{prefix}" if _version != version
130
+
131
+ hint = bytes.shift
132
+ if hint == 1
133
+ @mask = bytes.shift(8).reverse.pack('C*').unpack1('Q*')
134
+
135
+ @chain = MixinBot::UUID.new(hex: bytes.shift(16).pack('C*').unpack1('H*')).unpacked
136
+
137
+ class_length = bytes.shift
138
+ @nm_class = bytes.shift(class_length).pack('C*').unpack1('H*')
139
+
140
+ collection_length = bytes.shift
141
+ @collection = MixinBot::UUID.new(hex: bytes.shift(collection_length).pack('C*').unpack1('H*')).unpacked
142
+
143
+ token_length = bytes.shift
144
+ @token = MixinBot.utils.decode_int bytes.shift(token_length)
145
+ end
146
+
147
+ extra_length = bytes.shift
148
+ @extra = bytes.shift(extra_length).pack('C*').unpack1('H*')
149
+
150
+ self
151
+ end
152
+
153
+ def to_h
154
+ hash = {
155
+ prefix:,
156
+ version:,
157
+ mask:,
158
+ chain:,
159
+ class: nm_class,
160
+ collection:,
161
+ token:,
162
+ extra:,
163
+ memo:,
164
+ hex:
165
+ }
166
+
167
+ hash.each do |key, value|
168
+ hash.delete key if value.blank?
169
+ end
170
+
171
+ hash
172
+ end
173
+ end
174
+ end