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 +4 -4
- data/API_COVERAGE.md +13 -0
- data/CHANGELOG.md +30 -0
- data/README.md +17 -1
- data/lib/mixin_bot/api/asset.rb +5 -0
- data/lib/mixin_bot/api/auth.rb +20 -14
- data/lib/mixin_bot/api/blaze.rb +4 -2
- data/lib/mixin_bot/api/chain.rb +24 -2
- data/lib/mixin_bot/api/encrypted_message.rb +142 -5
- data/lib/mixin_bot/api/message.rb +2 -0
- data/lib/mixin_bot/api/session.rb +2 -2
- data/lib/mixin_bot/cache_store_adapter.rb +49 -0
- data/lib/mixin_bot/cli/base.rb +2 -1
- data/lib/mixin_bot/client.rb +3 -2
- data/lib/mixin_bot/configuration.rb +7 -0
- data/lib/mixin_bot/errors.rb +2 -3
- data/lib/mixin_bot/session_store.rb +26 -0
- data/lib/mixin_bot/utils/amount.rb +77 -0
- data/lib/mixin_bot/utils/crypto.rb +14 -0
- data/lib/mixin_bot/utils.rb +2 -0
- data/lib/mixin_bot/version.rb +1 -1
- data/lib/mixin_bot.rb +2 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ce614b6fab815a9d8dfb1e93914fdaf3ba6b7306b1f5d35ab594efcd0b46fe68
|
|
4
|
+
data.tar.gz: 70b09c5ba22efc597a43b560a4257e2984b059b8334eee6efa19498934e87407
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 |
|
data/lib/mixin_bot/api/asset.rb
CHANGED
|
@@ -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 }
|
data/lib/mixin_bot/api/auth.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
data/lib/mixin_bot/api/blaze.rb
CHANGED
|
@@ -101,8 +101,10 @@ module MixinBot
|
|
|
101
101
|
)
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
|
|
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:,
|
data/lib/mixin_bot/api/chain.rb
CHANGED
|
@@ -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
|
-
'
|
|
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
|
|
84
|
-
checksum =
|
|
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
|
|
96
|
-
silent
|
|
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
|
data/lib/mixin_bot/cli/base.rb
CHANGED
|
@@ -58,7 +58,8 @@ module MixinBot
|
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def utils_callable_methods
|
|
61
|
-
|
|
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)
|
data/lib/mixin_bot/client.rb
CHANGED
|
@@ -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]
|
data/lib/mixin_bot/errors.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
#
|
data/lib/mixin_bot/utils.rb
CHANGED
|
@@ -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
|
data/lib/mixin_bot/version.rb
CHANGED
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
|
+
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-
|
|
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
|