mixin_bot 2.4.1 → 2.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2015d5166c25f388692121bf3bd697cf87c3ce1533c250aea360592e7d5144b6
4
- data.tar.gz: d748e92727a3b7febdb14bf950267d62fc18c3dbb1bd457f27391ae807e63432
3
+ metadata.gz: ce614b6fab815a9d8dfb1e93914fdaf3ba6b7306b1f5d35ab594efcd0b46fe68
4
+ data.tar.gz: 70b09c5ba22efc597a43b560a4257e2984b059b8334eee6efa19498934e87407
5
5
  SHA512:
6
- metadata.gz: 92e232f9d704725e7def1a5457adacc120a5139f7e2b673841d80a46f15b7170f0b06653576d1f44da1e9bbb389de90951d89fb93b4b24ed801cfa43374e73f8
7
- data.tar.gz: d45e5bd3310a72468d19d826329d124f47fa220fcf9ff4dfaa17c5f5c01f26fe8d023c8c7a24c6fa2f6123e6b8f4cd6d7d08effcfe1749bf6150011dcbf13040
6
+ metadata.gz: ab61a3850c702cb36a00794f5cfacb3aa63d18d6fca24104d9be336b97b04332430eb702bba50d6bbaa80f9ad94976fa5aaeefaae69f736bed88b21ffce7f849
7
+ data.tar.gz: c2a237075b8c9399ef4f90d933d1bac288de35aefa11ff6fa55d3dd4e19a337d0a10f78173a9eef77a16476a63e208fa22b0c3250758ad7c2324490411ffa4b7
data/API_COVERAGE.md CHANGED
@@ -41,6 +41,7 @@ Status values: `done` | `alias` | `n/a` (CLI-only / config / platform-specific)
41
41
  | AssetBalance* | `API#asset_balance` | derived from outputs | done |
42
42
  | UserAssetBalance | `API#user_asset_balance` | GET `/safe/outputs` | done |
43
43
  | ReadAsset | `API#network_asset` | GET `/network/assets/:id` | done |
44
+ | ReadSafeFees | `API#safe_fees` / `#read_safe_fees` | GET `/safe/fees` | done |
44
45
  | ReadAssetTicker* | `API#network_ticker` | GET `/network/ticker` | done |
45
46
  | AssetSearch | `API#network_asset_search` | GET `/network/assets/search/:q` | done |
46
47
  | ReadNetworkAssets | `API#network_assets` | GET `/network` | done |
@@ -52,6 +53,8 @@ Status values: `done` | `alias` | `n/a` (CLI-only / config / platform-specific)
52
53
  | GetChainName | `API#chain_name` | local | done |
53
54
  | IsChainId | `API#chain_id?` | local | done |
54
55
  | GetFullChains | `API#full_chains` | local | done |
56
+ | ChainId constants (HyperEVM, X Layer, Robinhood, Pearl, corrected Sui) | `CHAIN_NAMES` + `CHAIN_STABLECOIN_ASSET_IDS` / `API#stablecoin_asset_ids` | local | done |
57
+ | USDT/USDC per-chain constants | `MixinBot::API::Chain::USDT_*` / `USDC_*` | local | done |
55
58
  | **Outputs / deposits** |
56
59
  | ListOutputs / ListUnspentOutputs | `API#safe_outputs` | GET `/safe/outputs` | done |
57
60
  | GetOutput | `API#safe_output` | GET `/safe/outputs/:id` | done |
@@ -94,6 +97,10 @@ Status values: `done` | `alias` | `n/a` (CLI-only / config / platform-specific)
94
97
  | RotateConversation | `API#rotate_conversation` | POST `.../rotate` | done |
95
98
  | UpdateParticipants | `API#add/remove/..._participants` | POST participants | done |
96
99
  | PostMessage(s) | `API#send_message` | POST `/messages` | done |
100
+ | MessageRequest.Silent | `API#send_*(silent: true)` | POST `/messages` | done |
101
+ | PostEncryptedMessages | `API#post_encrypted_messages` | POST `/encrypted_messages` + one-shot retry | done |
102
+ | SessionStore / MapSessionStore | `MixinBot::SessionStore` | local | done |
103
+ | EncryptedMessageResponse / EncryptedMessageError | response envelope `data` | per-recipient `state` | done |
97
104
  | EncryptMessageData / DecryptMessageData | `API#encrypt_message` / `#decrypt_message` | local | done |
98
105
  | BlazeClient send helpers | `API#blaze_send_*` | WebSocket | done |
99
106
  | **Inscriptions** |
@@ -139,6 +146,7 @@ Status values: `done` | `alias` | `n/a` (CLI-only / config / platform-specific)
139
146
  | CheckRetryableError | `MixinBot::Monitor.check_retryable_error` | local | done |
140
147
  | **HTTP config** |
141
148
  | Request / SetBaseUri / SetBlazeUri | `MixinBot::Configuration`, `Client` | config | n/a |
149
+ | SetHttpTimeout | `MixinBot.configure` `http_timeout` | config | n/a |
142
150
  | NewSafeUser | `MixinBot::Configuration` | config | n/a |
143
151
  | cli/*, examples/*, mixin/rpc main | `mixinbot call` / `mixinbot list` | CLI dispatch to `MixinBot::API` | done |
144
152
 
@@ -197,6 +205,8 @@ TS-only or Node-first REST surfaces. Ruby methods follow snake_case; aliases mir
197
205
  | conversation.* (CRUD/participants) | `API#conversation`, `#create_*`, `#join_*`, etc. | various | done |
198
206
  | **Message** |
199
207
  | message.sendAcknowledgement(s) | `API#acknowledge_message` / `#acknowledge_messages` | POST `/acknowledgements` | done |
208
+ | message.sendLegacy | `API#send_message(hash)` | POST `/messages` singular body | alias |
209
+ | APP_CARD cover_url / actions | `API#blaze_send_app_card(cover_url:, actions:)` | WebSocket | done |
200
210
  | message.sendSticker/Audio/Video/Live/Location/Transfer | `API#send_*_message` | POST `/messages` | done |
201
211
  | message.sendText/Image/File/Post/Contact/AppCard/AppButton/Recall | `API#send_*_message` | POST `/messages` | done |
202
212
  | **Code** |
@@ -210,6 +220,9 @@ TS-only or Node-first REST surfaces. Ruby methods follow snake_case; aliases mir
210
220
  | external.deposits | `API#transactions` (legacy) | GET `/external/transactions` | done |
211
221
  | external.checkAddress | `API#check_address` | GET `/external/addresses/check` | done |
212
222
  | external.exchangeRates | `API#fiats` | GET `/external/fiats` | done |
223
+ | **Utils** |
224
+ | utils/amount.formatUnits / parseUnits | `MixinBot.utils.format_units` / `#parse_units` | local | done |
225
+ | utils/auth.getChallenge | `MixinBot.utils.oauth_code_challenge` | local | done |
213
226
  | **Safe / UTXO / Transfer / Network / etc.** |
214
227
  | safe.* / utxo.* / transfer.* / network.* | spread across existing `API` modules | same HTTP paths as TS | done |
215
228
  | **Blaze** |
data/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.5.0] - 2026-09-03
11
+
12
+ ### Added
13
+
14
+ - **Upstream SDK parity (2026 Go/Node releases)** — closes gaps with `bot-api-go-client` v3.25.3 and `bot-api-nodejs-client` v7.5.6.
15
+ - **`API#safe_fees`** (`read_safe_fees` alias) — `GET /safe/fees` global Safe fee schedule.
16
+ - **`API#post_encrypted_messages`** — encrypt-and-send pipeline (`POST /encrypted_messages`) with one-shot retry on expired recipient sessions, per-message `SUCCESS`/`FAILED` state, and a pluggable session cache.
17
+ - **`MixinBot::SessionStore`** — in-memory `MapSessionStore` equivalent; pass any object answering `fetch(user_id)` / `store(user_id, sessions)` as `session_store:` (storing `nil` evicts).
18
+ - **`MixinBot::CacheStoreAdapter`** — wraps any `ActiveSupport::Cache`-style store (`Rails.cache` with Redis/Memcached/solid_cache, or a hand-rolled read/write/delete object) so encrypted-message sessions can be cached across processes with `expires_in:` TTLs.
19
+ - **`MixinBot::Configuration#http_timeout`** — opt-in Faraday request timeout in seconds (nil keeps the default).
20
+ - **`silent:` flag** on REST `send_message` and encrypted REST sends, matching Go `MessageRequest.Silent`.
21
+ - **Chain registry** — HyperEVM, X Layer, Robinhood, and Pearl chain ids, plus a corrected Sui id. `CHAIN_STABLECOIN_ASSET_IDS` map and `API#stablecoin_asset_ids(chain_id)`; per-chain `USDT_*` / `USDC_*` constants.
22
+ - **`MixinBot.utils.format_units` / `#parse_units`** — decimal-safe unit conversion (Node `utils/amount` parity, no `Kernel#Integer` octal/hex surprises).
23
+ - **`MixinBot.utils.oauth_code_challenge`** — PKCE S256 code challenge (RFC 7636).
24
+ - **OAuth PKCE support** — `oauth_token(code, code_verifier:)` and `authorize_code(scope:, code_verifier:)` exchange and emit PKCE authorizations.
25
+ - **`blaze_send_app_card(cover_url:, actions:)`** — APP_CARD parity with the Node SDK.
26
+
27
+ ### Changed
28
+
29
+ - **`API#post_encrypted_messages`** — encrypted-message checksum now reuses `MixinBot.utils.generate_user_checksum` (the dead MD5 line in `encrypt_message` is gone).
30
+ - **Encrypted-message session store** — default store lives on the API instance so caching persists across calls per process; expired sessions are evicted via `store(id, nil)` (Go `store.Delete` parity).
31
+ - **`MixinBot::Utils` CLI introspection** — `mixinbot utils list` / `utils call` now surface methods extended from submodules (`Address`, `Amount`, `Crypto`, …).
32
+ - **`API::Auth`** — `authorize_code` accepts `String` scopes; `REFRESH_OAUTH_CODE` payload is built from an `oauth_code_params` helper; the dead `@_app_id` ivar is gone.
33
+
34
+ ### Fixed
35
+
36
+ - **`API#post_encrypted_messages`** — non-String `data` is JSON-encoded (was Ruby `inspect` output); the `silent` flag is honored on all encrypted REST sends; sessions stores that raise or return non-arrays are tolerated (Go `err == nil` semantics); symbol-keyed sessions are normalized; per-message responses are merged across the retry so every message's final state is returned; duplicate `message_id`s are rejected; malformed `/encrypted_messages` payloads raise meaningful errors; kwargs are strictly `access_token` / `exp_in` / `scp`.
37
+ - **`MixinBot::Client`** — `Configuration#api_host` and `#debug` are now applied correctly even when the client is initialized with a different `config` (no more stale Faraday URL when reassigning config).
38
+ - **Encrypted-message test harness** — shared guarded WebMock setup in `test_helper.rb` so `LIVE=1` runs and per-file `setup` methods reset state consistently; encrypted-message stub helpers extracted for reuse.
39
+
10
40
  ## [2.4.1] - 2026-08-08
11
41
 
12
42
  ### Fixed
data/README.md CHANGED
@@ -55,6 +55,7 @@ MixinBot.configure do
55
55
  # self.pin = self.spend_key # optional; used where PIN material is required
56
56
  # self.api_host = 'api.mixin.one'
57
57
  # self.blaze_host = 'blaze.mixin.one'
58
+ # self.http_timeout = 30 # request timeout in seconds (nil = no timeout)
58
59
  # self.debug = true # Faraday response logging
59
60
  end
60
61
  ```
@@ -162,7 +163,7 @@ Many convenience methods on `MixinBot::API` still return the **inner `data` hash
162
163
  | **Withdraw** | `withdrawals`, `create_withdraw_address`, `check_address`, `withdraw_addresses` |
163
164
  | **Conversation** | `conversation`, `create_group_conversation`, `join_conversation`, … |
164
165
  | **Message** | `send_message`, `send_plain_messages`, Blaze helpers |
165
- | **EncryptedMessage** | `send_encrypted_*`, `encrypt_message`, `decrypt_message` |
166
+ | **EncryptedMessage** | `send_encrypted_*`, `post_encrypted_messages`, `encrypt_message`, `decrypt_message` |
166
167
  | **Blaze** | `blaze`, `start_blaze_connect`, `blaze_send_plain_text`, … |
167
168
  | **Attachment** | `create_attachment`, `upload_attachment` |
168
169
  | **Auth** | `oauth_token`, `authorize_code`, `access_token`, `sign_oauth_access_token` |
@@ -175,6 +176,21 @@ Many convenience methods on `MixinBot::API` still return the **inner `data` hash
175
176
 
176
177
  Top-level helpers on **`MixinBot::API`**: `access_token`, `encode_raw_transaction`, `decode_raw_transaction`, native variants via `mixin` CLI.
177
178
 
179
+ ### Session caching for encrypted messages
180
+
181
+ `post_encrypted_messages` resolves recipient sessions through a **session store**: `session_store: session_store_obj` — any object answering `fetch(user_id)` (sessions or `nil`) and `store(user_id, sessions)` (storing `nil` evicts). Without one, a per-API-instance in-memory store is used.
182
+
183
+ For Rails (or any `ActiveSupport::Cache`-style store — Redis, Memcached, solid_cache), use the built-in adapter for TTLs and cross-process caching:
184
+
185
+ ```ruby
186
+ MixinBot.api.post_encrypted_messages(
187
+ [{ recipient_id: id, category: 'ENCRYPTED_TEXT', data: 'hello' }],
188
+ session_store: MixinBot::CacheStoreAdapter.new(Rails.cache, expires_in: 12.hours)
189
+ )
190
+ ```
191
+
192
+ The adapter maps `store(user_id, nil)` to `cache.delete`, so sessions the server reports as expired are evicted, refreshed, and retried automatically.
193
+
178
194
  ### Other libraries
179
195
 
180
196
  | Area | Description |
@@ -116,6 +116,11 @@ module MixinBot
116
116
  end
117
117
  alias read_asset_fee asset_fee
118
118
 
119
+ def safe_fees(access_token: nil)
120
+ client.get '/safe/fees', access_token:
121
+ end
122
+ alias read_safe_fees safe_fees
123
+
119
124
  def asset_balance(asset_id)
120
125
  outputs = safe_outputs(asset: asset_id, state: 'unspent')
121
126
  Array(outputs['data']).sum { |o| o['amount'].to_d }
@@ -17,13 +17,14 @@ module MixinBot
17
17
  )
18
18
  end
19
19
 
20
- def oauth_token(code)
20
+ def oauth_token(code, code_verifier: nil)
21
21
  path = '/oauth/token'
22
22
  payload = {
23
23
  client_id: config.app_id,
24
24
  client_secret: config.client_secret,
25
25
  code:
26
26
  }
27
+ payload[:code_verifier] = code_verifier if code_verifier.present?
27
28
  client.post path, **payload
28
29
  end
29
30
 
@@ -37,9 +38,13 @@ module MixinBot
37
38
  end
38
39
 
39
40
  def authorize_code(**kwargs)
41
+ scope = kwargs[:scope] || ['PROFILE:READ']
42
+ scope = scope.split if scope.is_a?(String)
43
+
40
44
  data = authorization_data(
41
45
  kwargs[:app_id],
42
- kwargs[:scope] || ['PROFILE:READ']
46
+ scope,
47
+ kwargs[:code_verifier]
43
48
  )
44
49
 
45
50
  path = '/oauth/authorize'
@@ -67,21 +72,22 @@ module MixinBot
67
72
  end
68
73
  alias revoke_authorize revoke_authorization
69
74
 
70
- def authorization_data(app_id, scope = ['PROFILE:READ'])
71
- @_app_id = app_id
72
- @_scope = scope.join(' ')
75
+ # REFRESH_OAUTH_CODE params for the OAuth authorization handshake.
76
+ def oauth_code_params(app_id:, scope:, authorization_id: '', code_verifier: nil)
77
+ {
78
+ client_id: app_id,
79
+ scope:,
80
+ authorization_id:,
81
+ code_challenge: code_verifier ? MixinBot.utils.oauth_code_challenge(code_verifier) : ''
82
+ }
83
+ end
84
+
85
+ def authorization_data(app_id, scope = ['PROFILE:READ'], code_verifier = nil)
86
+ @_code_params = oauth_code_params(app_id:, scope: Array(scope).join, code_verifier:)
73
87
  EM.run do
74
88
  start_blaze_connect do
75
89
  def on_open(websocket, _event) # rubocop:disable Lint/NestedMethodDefinition
76
- websocket.send write_ws_message(
77
- action: 'REFRESH_OAUTH_CODE',
78
- params: {
79
- client_id: @_app_id,
80
- scope: @_scope,
81
- authorization_id: '',
82
- code_challenge: ''
83
- }
84
- )
90
+ websocket.send write_ws_message(action: 'REFRESH_OAUTH_CODE', params: @_code_params)
85
91
  end
86
92
 
87
93
  def on_message(websocket, event) # rubocop:disable Lint/NestedMethodDefinition
@@ -101,8 +101,10 @@ module MixinBot
101
101
  )
102
102
  end
103
103
 
104
- def blaze_send_app_card(socket, conversation_id:, recipient_id:, title:, description:, action:, icon_url:)
105
- data = { title:, description:, action:, icon_url: }.to_json
104
+ # mirrors the upstream APP_CARD fields
105
+ def blaze_send_app_card(socket, conversation_id:, recipient_id:, title:, description:, action:, icon_url:, # rubocop:disable Metrics/ParameterLists
106
+ cover_url: nil, actions: nil)
107
+ data = { title:, description:, action:, icon_url:, cover_url:, actions: }.compact.to_json
106
108
  socket.send write_ws_message(
107
109
  params: {
108
110
  conversation_id:,
@@ -57,13 +57,30 @@ module MixinBot
57
57
  '163a2142-398d-3483-aee3-d47db8da4d10' => 'MarsChain',
58
58
  'b12bb04a-1cea-401c-a086-0be61f544889' => 'XDC Network',
59
59
  'd2c1c7e1-a1a9-4f88-b282-d93b0a08b42b' => 'Aptos',
60
- '2bd97283-2582-33a8-bcba-f4b8ed189572' => 'Sui',
61
- 'ef660437-d915-4e27-ad3f-632bfb6ba0ee' => 'TON'
60
+ '3acb25e4-6216-35c3-b1ca-87184269ee08' => 'Sui',
61
+ 'ef660437-d915-4e27-ad3f-632bfb6ba0ee' => 'TON',
62
+ '36d23d9e-bf4e-3ede-a12d-26f1f1f9fd2f' => 'HyperEVM',
63
+ '37f5a4d1-905f-3b34-8291-c37438c7dcfc' => 'X Layer',
64
+ 'b304e03d-d004-3102-875b-8266f8407a1a' => 'Robinhood',
65
+ 'e1bf305c-0d49-397d-85bd-55b9eaadafba' => 'Pearl'
62
66
  }.freeze
63
67
 
64
68
  XIN_ASSET_ID = 'c94ac88f-4671-3976-b60a-09064f1811e8'
65
69
  VAULTA_ASSET_ID = 'ac2b79f3-ec9c-3d87-b4ca-3e825228dda5'
66
70
 
71
+ # Stablecoin asset ids per chain, mirroring upstream bot-api-go-client asset.go.
72
+ USDT_HYPEREVM = '3782f986-a053-33ae-b6bf-460abb62ce49'
73
+ USDT_XLAYER = 'c4d9746a-20be-321c-baca-d378534dd4eb'
74
+ USDC_HYPEREVM = '1e01fede-51fa-3791-9b06-5c18801b272c'
75
+ USDC_XLAYER = '8d706a25-514c-3c73-9446-c25fd07d0ae2'
76
+ USDC_SUI = 'a0f7ad61-3b9f-30f3-a1de-cd831aec33ff'
77
+
78
+ CHAIN_STABLECOIN_ASSET_IDS = {
79
+ '36d23d9e-bf4e-3ede-a12d-26f1f1f9fd2f' => { usdt: USDT_HYPEREVM, usdc: USDC_HYPEREVM }, # HyperEVM
80
+ '37f5a4d1-905f-3b34-8291-c37438c7dcfc' => { usdt: USDT_XLAYER, usdc: USDC_XLAYER }, # X Layer
81
+ '3acb25e4-6216-35c3-b1ca-87184269ee08' => { usdc: USDC_SUI } # Sui
82
+ }.freeze
83
+
67
84
  def network_chain(chain_id)
68
85
  path = format('/network/chains/%<chain_id>s', chain_id:)
69
86
  client.get path, access_token: ''
@@ -78,6 +95,11 @@ module MixinBot
78
95
  def chain_name(chain_id)
79
96
  CHAIN_NAMES[chain_id] || 'Not Supported Chain'
80
97
  end
98
+
99
+ # Stablecoin asset ids for a chain id (nil for chains without a mapping).
100
+ def stablecoin_asset_ids(chain_id)
101
+ CHAIN_STABLECOIN_ASSET_IDS[chain_id]
102
+ end
81
103
  alias get_chain_name chain_name
82
104
 
83
105
  def chain_id?(chain_id)
@@ -80,8 +80,8 @@ module MixinBot
80
80
  def base_encrypted_message_params(options)
81
81
  data = options[:data].is_a?(String) ? options[:data] : options[:data].to_json
82
82
  data_base64 = encrypt_message Base64.urlsafe_encode64(data, padding: false), options[:sessions]
83
- session_ids = options[:sessions].map(&->(s) { s['session_id'] }).sort
84
- checksum = Digest::MD5.hexdigest session_ids.join
83
+ session_ids = options[:sessions].map { |s| s['session_id'] || s[:session_id] }.compact.sort
84
+ checksum = MixinBot.utils.generate_user_checksum(options[:sessions])
85
85
 
86
86
  {
87
87
  conversation_id: options[:conversation_id],
@@ -92,8 +92,9 @@ module MixinBot
92
92
  message_id: options[:message_id] || SecureRandom.uuid,
93
93
  data_base64:,
94
94
  checksum:,
95
- recipient_sessions: session_ids.map(&->(s) { { session_id: s } }),
96
- silent: false
95
+ recipient_sessions: session_ids.map { |session_id| { session_id: } },
96
+ # Go's MessageRequest always serializes `silent`; default stays false
97
+ silent: (options[:silent] ? true : false)
97
98
  }.compact
98
99
  end
99
100
 
@@ -110,13 +111,149 @@ module MixinBot
110
111
  client.post path, *payload
111
112
  end
112
113
 
114
+ ##
115
+ # Encrypt and send messages using each recipient's current sessions.
116
+ #
117
+ # Accepts a single options hash or an array of them (same shapes as
118
+ # +send_encrypted_*_message+). Sessions are resolved through the session
119
+ # store - +session_store:+, or the per-API-instance default store - and
120
+ # fetched via +POST /sessions/fetch+ on a cache miss. Messages the server
121
+ # rejects because a recipient's sessions changed have their sessions
122
+ # evicted, refreshed, and are retried once. The returned envelope carries
123
+ # every message's final +state+ (SUCCESS/FAILED).
124
+ #
125
+ # @param messages [Hash, Array<Hash>] unencrypted message options
126
+ # @param session_store [#fetch, #store] session cache; +store(id, nil)+ evicts
127
+ # @return [MixinBot::Models::ApiEnvelope] per-message responses (+state+ SUCCESS/FAILED)
128
+ #
129
+ def post_encrypted_messages(messages = nil, session_store: nil, **kwargs)
130
+ messages = kwargs.delete(:messages) if messages.nil? && kwargs.key?(:messages)
131
+ messages = [messages] unless messages.is_a? Array
132
+ raise ArgumentError, 'messages must not be empty' if messages.empty?
133
+
134
+ token_keys = %i[access_token exp_in scp]
135
+ unknown = kwargs.keys - token_keys
136
+ raise ArgumentError, "unsupported options: #{unknown.join(', ')}" if unknown.any?
137
+
138
+ original = messages.map { |message| message.to_h.transform_keys(&:to_sym) }
139
+ seen_ids = []
140
+ pending = original.map do |message|
141
+ raise ArgumentError, 'recipient_id is required' if message[:recipient_id].blank?
142
+ if message[:message_id] && seen_ids.include?(message[:message_id])
143
+ raise ArgumentError,
144
+ "duplicate message_id #{message[:message_id]}"
145
+ end
146
+
147
+ message[:message_id] ||= SecureRandom.uuid
148
+ seen_ids << message[:message_id]
149
+ message
150
+ end
151
+
152
+ store = session_store || (@session_store ||= MixinBot::SessionStore.new)
153
+ final_results = {}
154
+ responses = nil
155
+
156
+ 2.times do |attempt|
157
+ recipient_ids = pending.map { |message| message[:recipient_id] }.uniq
158
+ missing = recipient_ids.reject { |recipient_id| cached_encrypted_sessions(store, recipient_id).present? }
159
+ fetched = missing.any? ? fetch_encrypted_message_sessions!(missing, store, **kwargs) : {}
160
+
161
+ requests = pending.map do |message|
162
+ sessions = cached_encrypted_sessions(store, message[:recipient_id]) || fetched[message[:recipient_id]]
163
+ raise ArgumentError, "no sessions found for recipient #{message[:recipient_id]}" if sessions.blank?
164
+
165
+ encrypted_message_request(message, sessions)
166
+ end
167
+ responses = client.post '/encrypted_messages', *requests, **kwargs
168
+
169
+ data = responses['data']
170
+ data = [data] if data.is_a?(Hash)
171
+ raise ArgumentError, 'unexpected /encrypted_messages response format' unless data.is_a? Array
172
+
173
+ results, failures = encrypted_message_results(pending, data)
174
+ final_results.merge!(results)
175
+ break if failures.empty? || attempt.positive?
176
+
177
+ pending = failures
178
+ # the FAILED recipients' sessions are assumed expired: evict + refresh
179
+ pending.map { |message| message[:recipient_id] }.uniq.each { |recipient_id| store.store recipient_id, nil }
180
+ end
181
+
182
+ responses.to_h['data'] = original.filter_map { |message| final_results[message[:message_id]] }
183
+ responses
184
+ end
185
+
186
+ # Read a recipient's sessions from the store. Stores that raise (a Redis
187
+ # outage) or return non-list values count as a cache miss, mirroring the
188
+ # Go SDK's `if sessions, err := store.Get(...); err == nil` tolerance.
189
+ def cached_encrypted_sessions(store, recipient_id)
190
+ cached = begin
191
+ # block form keeps 1-arity duck-typed stores (Hash included) working
192
+ store.fetch(recipient_id) { nil } # rubocop:disable Style/RedundantFetchBlock
193
+ rescue StandardError
194
+ nil
195
+ end
196
+
197
+ sessions = Array(cached)
198
+ return nil unless sessions.all?(Hash)
199
+ return nil if sessions.empty?
200
+
201
+ sessions.map(&:stringify_keys)
202
+ end
203
+
204
+ # Fetch sessions for the given recipients and write them to the store.
205
+ # Returns { recipient_id => sessions } for the resolved recipients.
206
+ def fetch_encrypted_message_sessions!(recipient_ids, store, **kwargs)
207
+ return {} if recipient_ids.empty?
208
+
209
+ response = fetch_user_sessions(recipient_ids, **kwargs.slice(:access_token, :exp_in, :scp))
210
+ grouped = {}
211
+ Array(response['data']).each do |session|
212
+ owner = session['user_id'].presence
213
+ owner = recipient_ids.first if owner.blank? && recipient_ids.one?
214
+ grouped[owner] = (grouped[owner] || []) + [session] if owner.present?
215
+ end
216
+
217
+ recipient_ids.each_with_object({}) do |recipient_id, resolved|
218
+ sessions = grouped[recipient_id]
219
+ raise ArgumentError, "no sessions found for recipient #{recipient_id}" if sessions.blank?
220
+
221
+ store.store recipient_id, sessions.map(&:stringify_keys)
222
+ resolved[recipient_id] = sessions.map(&:stringify_keys)
223
+ end
224
+ end
225
+
226
+ def encrypted_message_request(message, sessions)
227
+ base_encrypted_message_params message.merge(sessions:, silent: (message[:silent] ? true : false))
228
+ end
229
+
230
+ # Maps each pending message to its server response. Returns
231
+ # [results_by_message_id, failed_messages]; raises on malformed responses.
232
+ def encrypted_message_results(messages, responses)
233
+ by_message_id = responses.to_h { |response| [response['message_id'], response] }
234
+
235
+ results = {}
236
+ failures = []
237
+ messages.each do |message|
238
+ response = by_message_id[message[:message_id]]
239
+ raise ArgumentError, "encrypted message response missing for #{message[:message_id]}" if response.nil?
240
+
241
+ results[message[:message_id]] = response
242
+ case response['state']
243
+ when 'SUCCESS' then nil
244
+ when 'FAILED' then failures << message
245
+ else raise ArgumentError, "encrypted message #{message[:message_id]} returned unknown state #{response['state']}"
246
+ end
247
+ end
248
+ [results, failures]
249
+ end
250
+
113
251
  def encrypt_message(data, sessions = [], sk: nil, pk: nil) # rubocop:disable Naming/MethodParameterName
114
252
  raise ArgumentError, 'Wrong sessions format!' unless sessions.all?(&->(s) { s.key?('session_id') && s.key?('public_key') })
115
253
 
116
254
  sk ||= config.session_private_key[0...32]
117
255
  pk ||= config.session_private_key[32...]
118
256
 
119
- Digest::MD5.hexdigest sessions.map(&->(s) { s['session_id'] }).sort.join
120
257
  encrypter = OpenSSL::Cipher.new('AES-128-GCM').encrypt
121
258
  key = encrypter.random_key
122
259
  nounce = encrypter.random_iv
@@ -92,6 +92,8 @@ module MixinBot
92
92
  status: 'SENT',
93
93
  quote_message_id: options[:quote_message_id],
94
94
  message_id: options[:message_id] || SecureRandom.uuid,
95
+ # opt-in silent delivery; key is omitted unless requested (mirrors Go omitempty)
96
+ silent: (options[:silent] ? true : nil),
95
97
  data: Base64.encode64(data)
96
98
  }.compact
97
99
  end
@@ -3,10 +3,10 @@
3
3
  module MixinBot
4
4
  class API
5
5
  module Session
6
- def fetch_user_sessions(user_ids, access_token: nil)
6
+ def fetch_user_sessions(user_ids, access_token: nil, exp_in: 600, scp: 'FULL')
7
7
  raise ArgumentError, 'user_ids required' if user_ids.blank?
8
8
 
9
- client.fetch_post_array '/sessions/fetch', Array(user_ids), access_token:
9
+ client.fetch_post_array '/sessions/fetch', Array(user_ids), access_token:, exp_in:, scp:
10
10
  end
11
11
  alias fetch_user_session fetch_user_sessions
12
12
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ ##
5
+ # Adapter that lets any ActiveSupport::Cache-style store (Rails.cache with a
6
+ # Redis/Memcached/solid_cache backend, or a hand-rolled read/write/delete
7
+ # object) back the encrypted-message session cache.
8
+ #
9
+ # API#post_encrypted_messages expects a store answering +fetch(user_id)+ and
10
+ # +store(user_id, sessions)+, where storing +nil+ evicts. ActiveSupport
11
+ # caches speak read/write/delete instead, so this adapter translates:
12
+ #
13
+ # session_store: MixinBot::CacheStoreAdapter.new(Rails.cache, expires_in: 12.hours)
14
+ #
15
+ # The +expires_in:+ option is passed through to +cache.write+ as a TTL; the
16
+ # pipeline's one-shot expiry retry covers entries that lapse between fetch
17
+ # and send.
18
+ #
19
+ class CacheStoreAdapter
20
+ KEY_PREFIX = 'mixin_bot/sessions'
21
+
22
+ def initialize(cache = nil, expires_in: nil)
23
+ cache ||= Rails.cache if defined?(Rails)
24
+ raise ArgumentError, 'a cache store is required' if cache.nil?
25
+
26
+ @cache = cache
27
+ @expires_in = expires_in
28
+ end
29
+
30
+ # Returns the cached session list for the recipient, or nil on a miss.
31
+ # A block passed by the caller is intentionally ignored: the block form of
32
+ # Rails' fetch means read-through caching, which is not wanted here.
33
+ def fetch(user_id)
34
+ @cache.read("#{KEY_PREFIX}/#{user_id}")
35
+ end
36
+
37
+ # Writes the session list for the recipient; +nil+ evicts the entry.
38
+ def store(user_id, sessions)
39
+ key = "#{KEY_PREFIX}/#{user_id}"
40
+ if sessions.nil?
41
+ @cache.delete(key)
42
+ elsif @expires_in
43
+ @cache.write(key, sessions, expires_in: @expires_in)
44
+ else
45
+ @cache.write(key, sessions)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -58,7 +58,8 @@ module MixinBot
58
58
  end
59
59
 
60
60
  def utils_callable_methods
61
- MixinBot::Utils.singleton_methods(false).sort
61
+ # include methods from the extend-ed submodules (Address, Amount, Crypto, ...)
62
+ MixinBot::Utils.singleton_methods.sort
62
63
  end
63
64
 
64
65
  def api_method_owner(method_name)
@@ -14,7 +14,7 @@ module MixinBot
14
14
  def initialize(config)
15
15
  @config = config || MixinBot.config
16
16
  @conn = Faraday.new(
17
- url: "#{SERVER_SCHEME}://#{config.api_host}",
17
+ url: "#{SERVER_SCHEME}://#{@config.api_host}",
18
18
  headers: {
19
19
  'Content-Type' => 'application/json',
20
20
  'User-Agent' => "mixin_bot/#{MixinBot::VERSION}"
@@ -23,8 +23,9 @@ module MixinBot
23
23
  f.request :json
24
24
  f.request :retry, max: 2, interval: 0.5, interval_randomness: 0.5, backoff_factor: 2,
25
25
  exceptions: [Faraday::ConnectionFailed, Faraday::TimeoutError]
26
+ f.options.timeout = @config.http_timeout if @config.http_timeout
26
27
  f.response :json
27
- f.response :logger if config.debug
28
+ f.response :logger if @config.debug
28
29
  end
29
30
  end
30
31
 
@@ -52,6 +52,7 @@ module MixinBot
52
52
  pin
53
53
  api_host
54
54
  blaze_host
55
+ http_timeout
55
56
  session_private_key_curve25519
56
57
  server_public_key_curve25519
57
58
  debug
@@ -71,6 +72,7 @@ module MixinBot
71
72
  # @option kwargs [String] :pin the PIN (defaults to spend_key if not provided)
72
73
  # @option kwargs [String] :api_host ('api.mixin.one') the API host
73
74
  # @option kwargs [String] :blaze_host ('blaze.mixin.one') the Blaze WebSocket host
75
+ # @option kwargs [Numeric] :http_timeout (nil) request timeout in seconds; nil keeps Faraday's default
74
76
  # @option kwargs [Boolean] :debug (false) enable debug logging
75
77
  #
76
78
  # @example
@@ -87,6 +89,11 @@ module MixinBot
87
89
  @session_id = kwargs[:session_id]
88
90
  @api_host = kwargs[:api_host] || 'api.mixin.one'
89
91
  @blaze_host = kwargs[:blaze_host] || 'blaze.mixin.one'
92
+ @http_timeout = kwargs[:http_timeout]
93
+ if @http_timeout && (@http_timeout.is_a?(Numeric) ? @http_timeout <= 0 : true)
94
+ raise ArgumentError, "http_timeout must be a positive number of seconds, got #{@http_timeout.inspect}"
95
+ end
96
+
90
97
  @debug = kwargs[:debug] || false
91
98
 
92
99
  self.session_private_key = kwargs[:session_private_key] || kwargs[:private_key]
@@ -28,8 +28,8 @@ module MixinBot
28
28
  attr_reader :code, :description, :status, :http_status, :request_id, :server_time,
29
29
  :retry_after, :extra, :path, :verb, :body
30
30
 
31
- # rubocop:disable Metrics/ParameterLists -- structured API error metadata
32
- def initialize(message = nil, code: nil, description: nil, status: nil, http_status: nil,
31
+ # structured API error metadata
32
+ def initialize(message = nil, code: nil, description: nil, status: nil, http_status: nil, # rubocop:disable Metrics/ParameterLists
33
33
  request_id: nil, server_time: nil, retry_after: nil, extra: nil,
34
34
  path: nil, verb: nil, body: nil)
35
35
  @code = code&.to_i
@@ -45,7 +45,6 @@ module MixinBot
45
45
  @body = body
46
46
  super(message || formatted_message)
47
47
  end
48
- # rubocop:enable Metrics/ParameterLists
49
48
 
50
49
  def client_error?
51
50
  c = code.to_i
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ ##
5
+ # In-memory cache of recipient sessions for encrypted-message sending.
6
+ #
7
+ # Mirrors the Go SDK's +MapSessionStore+: +fetch+ returns the cached session
8
+ # list for a recipient user (or nil on a miss) and +store+ overwrites it;
9
+ # storing +nil+ evicts the entry (used when sessions expire mid-send).
10
+ # Any object answering both calls can be passed as +session_store:+ to
11
+ # API#post_encrypted_messages to plug in custom caching (e.g. Redis, TTLs).
12
+ #
13
+ class SessionStore
14
+ def initialize
15
+ @sessions = {}
16
+ end
17
+
18
+ def fetch(user_id)
19
+ @sessions[user_id]
20
+ end
21
+
22
+ def store(user_id, sessions)
23
+ @sessions[user_id] = sessions
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module MixinBot
6
+ module Utils
7
+ ##
8
+ # Decimal-safe unit conversion between human-readable amounts and decimal
9
+ # strings, mirroring the Node SDK's utils/amount.ts (BigNumber-based).
10
+ #
11
+ module Amount
12
+ DECIMAL_STRING_PATTERN = /\A[+-]?\d+(\.\d+)?\z/
13
+
14
+ ##
15
+ # Divide an amount by 10**decimals and return the exact decimal string.
16
+ # Accepts Integers and decimal strings ('7.5'); leading zeros are decimal,
17
+ # never octal. Mirrors BigNumber(amount).dividedBy(10**unit).
18
+ #
19
+ # MixinBot.utils.format_units(10**17, 18) # => "0.1"
20
+ # MixinBot.utils.format_units('010', 18) # => "0.00000000000000001"
21
+ # MixinBot.utils.format_units('7.5', 2) # => "0.075"
22
+ #
23
+ def format_units(amount, decimals)
24
+ decimals = validated_decimals(decimals)
25
+ value = validated_amount_string(amount)
26
+
27
+ sign = value.start_with?('-') ? '-' : ''
28
+ whole, fraction = value.delete_prefix('-').delete_prefix('+').split('.')
29
+ fraction ||= ''
30
+ digits = "#{whole}#{fraction}".sub(/\A0+(?=\d)/, '')
31
+ shift = fraction.length + decimals
32
+
33
+ formatted =
34
+ if shift.zero?
35
+ digits
36
+ elsif digits.size > shift
37
+ "#{digits[0...-shift]}.#{digits[-shift..]}"
38
+ else
39
+ "0.#{'0' * (shift - digits.size)}#{digits}"
40
+ end
41
+
42
+ "#{sign}#{formatted}".sub(/(\.\d*?)0+\z/, '\1').sub(/\.\z/, '')
43
+ end
44
+
45
+ ##
46
+ # Multiply a decimal amount string by 10**decimals and return integer
47
+ # minor units. Values with more precision than +decimals+ round down
48
+ # (floor), matching the Node SDK; invalid strings raise +ArgumentError+.
49
+ #
50
+ # MixinBot.utils.parse_units('0.1', 18) # => 100000000000000000
51
+ #
52
+ def parse_units(amount, decimals)
53
+ decimals = validated_decimals(decimals)
54
+ value = validated_amount_string(amount)
55
+
56
+ (value.to_d * (10**decimals)).floor
57
+ end
58
+
59
+ private
60
+
61
+ def validated_amount_string(amount)
62
+ value = amount.to_s.strip
63
+ raise ArgumentError, "invalid amount #{amount.inspect}" unless value.match?(DECIMAL_STRING_PATTERN)
64
+
65
+ value
66
+ end
67
+
68
+ # accepts Integers and numeric strings (base-10, so '010' is ten, never octal)
69
+ def validated_decimals(decimals)
70
+ decimals = Integer(decimals, 10) if decimals.is_a?(String) && decimals.match?(/\A[+-]?\d+\z/)
71
+ return decimals if decimals.is_a?(Integer) && decimals >= 0
72
+
73
+ raise ArgumentError, "decimals must be a non-negative Integer, got #{decimals.inspect}"
74
+ end
75
+ end
76
+ end
77
+ end
@@ -95,6 +95,20 @@ module MixinBot
95
95
  JOSE::JWT.sign(jwk, jws, jwt).compact
96
96
  end
97
97
 
98
+ ##
99
+ # Derives an OAuth PKCE code challenge (S256) from a code verifier:
100
+ # base64url(SHA-256(verifier)) without padding.
101
+ #
102
+ # @param verifier [String] the PKCE code verifier
103
+ # @return [String] the code challenge
104
+ #
105
+ # @example
106
+ # MixinBot.utils.oauth_code_challenge(verifier)
107
+ #
108
+ def oauth_code_challenge(verifier)
109
+ Base64.urlsafe_encode64 Digest::SHA256.digest(verifier), padding: false
110
+ end
111
+
98
112
  ##
99
113
  # Generates a new Ed25519 keypair.
100
114
  #
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'utils/address'
4
+ require_relative 'utils/amount'
4
5
  require_relative 'utils/crypto'
5
6
  require_relative 'utils/decoder'
6
7
  require_relative 'utils/encoder'
@@ -53,6 +54,7 @@ module MixinBot
53
54
  #
54
55
  module Utils
55
56
  extend MixinBot::Utils::Address
57
+ extend MixinBot::Utils::Amount
56
58
  extend MixinBot::Utils::Crypto
57
59
  extend MixinBot::Utils::Decoder
58
60
  extend MixinBot::Utils::Encoder
@@ -11,5 +11,5 @@ module MixinBot
11
11
  #
12
12
  # @see https://semver.org/
13
13
  #
14
- VERSION = '2.4.1'
14
+ VERSION = '2.5.0'
15
15
  end
data/lib/mixin_bot.rb CHANGED
@@ -23,6 +23,8 @@ require 'sha3'
23
23
  require_relative 'mixin_bot/errors'
24
24
  require_relative 'mixin_bot/address'
25
25
  require_relative 'mixin_bot/models'
26
+ require_relative 'mixin_bot/session_store'
27
+ require_relative 'mixin_bot/cache_store_adapter'
26
28
  require_relative 'mixin_bot/api'
27
29
  require_relative 'mixin_bot/bot_auth'
28
30
  require_relative 'mixin_bot/cli'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mixin_bot
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.4.1
4
+ version: 2.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - an-lee
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-08 00:00:00.000000000 Z
11
+ date: 2026-09-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -318,6 +318,7 @@ files:
318
318
  - lib/mixin_bot/api/user.rb
319
319
  - lib/mixin_bot/api/withdraw.rb
320
320
  - lib/mixin_bot/bot_auth.rb
321
+ - lib/mixin_bot/cache_store_adapter.rb
321
322
  - lib/mixin_bot/cli.rb
322
323
  - lib/mixin_bot/cli/api.rb
323
324
  - lib/mixin_bot/cli/base.rb
@@ -345,6 +346,7 @@ files:
345
346
  - lib/mixin_bot/models/user.rb
346
347
  - lib/mixin_bot/monitor.rb
347
348
  - lib/mixin_bot/nfo.rb
349
+ - lib/mixin_bot/session_store.rb
348
350
  - lib/mixin_bot/transaction.rb
349
351
  - lib/mixin_bot/transaction/buffer.rb
350
352
  - lib/mixin_bot/transaction/decoder.rb
@@ -352,6 +354,7 @@ files:
352
354
  - lib/mixin_bot/url_scheme.rb
353
355
  - lib/mixin_bot/utils.rb
354
356
  - lib/mixin_bot/utils/address.rb
357
+ - lib/mixin_bot/utils/amount.rb
355
358
  - lib/mixin_bot/utils/crypto.rb
356
359
  - lib/mixin_bot/utils/decoder.rb
357
360
  - lib/mixin_bot/utils/encoder.rb