fragment-donor-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE +21 -0
- data/README.md +117 -0
- data/lib/fragment_donor_sdk.rb +355 -0
- metadata +107 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4e091c1109aab4095fd1f9f1b840d3323dbceac0f783134ce757b8d24f4f91d2
|
|
4
|
+
data.tar.gz: fd2b437c5b42b49df2ccf190ba008c7267e19f1d8601bcb0595259f353a0cbce
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 265973d1d1f4e562ac7b90ef84f429dd80bdf3339edf398e8e9865efbc3eea6f0f095beb5ee7606e30c802e5be0439f771354bb48a73ef9647c991196d1b58d1
|
|
7
|
+
data.tar.gz: c27b15476d7f538ab87cfad6008a9b910cde9041efd8f5e799c9dedb7314a57bd037425d376984b52e937b25119ef3e7b3717f5c041f0e5892d1589c16bcc2da
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Initial four-method server-side API client with typed response structs.
|
|
6
|
+
- Preserves exact decimal balances and unknown fields; errors redact credentials.
|
|
7
|
+
- Bounded optional read retry; purchase retries and redirects disabled.
|
|
8
|
+
- Safe structured error details and finite timeout configuration validation.
|
|
9
|
+
- PurchaseOutcomeUnknownError and uncertainty flag preserve ambiguous purchases.
|
|
10
|
+
- Syntax/whitespace gates and allowlisted installed-gem consumer verification.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fragment Donor SDK contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Fragment Donor Ruby SDK
|
|
2
|
+
|
|
3
|
+
Independent server-side Ruby 3.2+ client. Not an official Telegram, Fragment or
|
|
4
|
+
TON product. Initial gem `fragment-donor-sdk` version `0.1.0`.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
After publication: `gem install fragment-donor-sdk -v 0.1.0`.
|
|
9
|
+
Before publication: `gem build fragment-donor-sdk.gemspec`, then
|
|
10
|
+
`gem install --local fragment-donor-sdk-0.1.0.gem`.
|
|
11
|
+
|
|
12
|
+
## All four operations
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
require "fragment_donor_sdk"
|
|
16
|
+
|
|
17
|
+
client = FragmentDonor::Client.new(
|
|
18
|
+
credentials: FragmentDonor::Credentials.new(
|
|
19
|
+
mnemonic: ENV.fetch("FRAGMENT_MNEMONIC"),
|
|
20
|
+
cookie: ENV.fetch("FRAGMENT_COOKIE"),
|
|
21
|
+
wallet_version: "auto",
|
|
22
|
+
provider_key: ENV["TONCONSOLE_API_KEY"] # optional provider key only
|
|
23
|
+
),
|
|
24
|
+
connect_timeout: 5,
|
|
25
|
+
request_timeout: 30
|
|
26
|
+
)
|
|
27
|
+
user = client.get_user_info("durov")
|
|
28
|
+
balance = client.wallet_balance
|
|
29
|
+
# Exact strings; do not convert to Float.
|
|
30
|
+
puts balance.ton
|
|
31
|
+
|
|
32
|
+
# Real-funds operations require both opt-in and one selected purchase.
|
|
33
|
+
if ENV["FRAGMENT_ALLOW_PURCHASES"] == "yes"
|
|
34
|
+
begin
|
|
35
|
+
case ENV["FRAGMENT_PURCHASE_KIND"]
|
|
36
|
+
when "stars" then client.buy_stars("durov", 50, payment_method: "usdt_ton")
|
|
37
|
+
when "premium" then client.buy_premium("durov", 3, payment_method: "ton")
|
|
38
|
+
end
|
|
39
|
+
rescue FragmentDonor::PurchaseOutcomeUnknownError => error
|
|
40
|
+
# Reconcile error.details["tx_hash"] manually. Do not repeat the purchase.
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
No service account, login, `Authorization` or `X-Api-Key` is needed. Cookie and
|
|
46
|
+
Mnemonic are purchase credentials; balance needs only Mnemonic. `Api-Key` is
|
|
47
|
+
an optional TonConsole provider key. Stars: integer 50–1,000,000. Premium:
|
|
48
|
+
3/6/12 months. Payment: `usdt_ton` (backend default when omitted) or `ton`.
|
|
49
|
+
Wallet versions: `auto`, `v5r1`, `v4r2`, `v3r2`. Wallet address, Fragment Proxy
|
|
50
|
+
and User-Agent are optional Credentials fields. Purchases are form-encoded.
|
|
51
|
+
The backend accepts GET and POST for balance; this client chooses GET.
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
begin
|
|
55
|
+
user = client.get_user_info("durov")
|
|
56
|
+
rescue FragmentDonor::RateLimitError => error
|
|
57
|
+
puts "Wait #{error.retry_after} seconds before your next request"
|
|
58
|
+
rescue FragmentDonor::UnavailableError => error
|
|
59
|
+
puts "Temporarily unavailable; wait #{error.retry_after} seconds"
|
|
60
|
+
rescue FragmentDonor::TimeoutError, FragmentDonor::NetworkError
|
|
61
|
+
# Purchase outcome may be unknown. Never blindly repeat a purchase.
|
|
62
|
+
rescue FragmentDonor::ValidationError, FragmentDonor::APIError,
|
|
63
|
+
FragmentDonor::MalformedResponseError => error
|
|
64
|
+
warn error.message # SDK messages redact configured credential values.
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Error `details` retains recursively redacted JSON fields such as `info`,
|
|
69
|
+
`tx_hash`, `unconfirmed`, `transient` and unknown fields for reconciliation.
|
|
70
|
+
An explicit purchase `unconfirmed: true` raises `PurchaseOutcomeUnknownError`
|
|
71
|
+
with `outcome_unknown? == true`, including HTTP 400. Timeouts/network failures,
|
|
72
|
+
malformed replies, redirects and ambiguous 5xx also set the uncertainty flag.
|
|
73
|
+
Known pre-purchase limiter codes retain RateLimitError/UnavailableError.
|
|
74
|
+
Reconcile `details["tx_hash"]` manually and never replay the purchase. A false
|
|
75
|
+
flag is not a rejection/idempotency guarantee. Remote HTTP 400/422 failures
|
|
76
|
+
are APIError; ValidationError is reserved for SDK preflight validation.
|
|
77
|
+
|
|
78
|
+
The shared per-IP limit is normally 30/minute. Retry hints understand HTTP
|
|
79
|
+
seconds, HTTP date, JSON `retry_after` and `flood_wait`. Default retry count is
|
|
80
|
+
zero. Opt-in `read_retries: 2, automatic_wait: true` allows read-only retries;
|
|
81
|
+
waits above `max_wait: 60` return immediately without retrying too early.
|
|
82
|
+
**Purchases always make one attempt**, even under opt-in retry configuration.
|
|
83
|
+
There is no backend idempotency guarantee. Redirects are not followed.
|
|
84
|
+
Injected transports must not retry or log credential-bearing requests.
|
|
85
|
+
|
|
86
|
+
Typed `UserInfo`, `Purchase`, `WalletBalance` preserve unknown fields in `extra`;
|
|
87
|
+
`ton`/`usdt_ton` are strings. Debug/JSON representations of Client/Credentials
|
|
88
|
+
are redacted and transport exception causes are discarded. Do not dump
|
|
89
|
+
arbitrary request or response internals. Use dedicated, minimally funded
|
|
90
|
+
wallets and server-side secret storage. The inspected backend stores submitted
|
|
91
|
+
credentials; do not claim zero retention.
|
|
92
|
+
|
|
93
|
+
## Tests/build/release
|
|
94
|
+
|
|
95
|
+
From this directory in the monorepo:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
ruby scripts/check.rb
|
|
99
|
+
ruby -Ilib test/client_test.rb
|
|
100
|
+
gem build fragment-donor-sdk.gemspec
|
|
101
|
+
ruby smoke/verify.rb
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`scripts/check.rb` enforces Ruby syntax plus tabs/trailing-space/final-newline
|
|
105
|
+
consistency (not a full RuboCop style claim). The smoke inspects the gem's exact
|
|
106
|
+
file allowlist/fixture-private-key exclusion, installs the built artifact into
|
|
107
|
+
an isolated GEM_HOME and exercises all four methods with mocked transport,
|
|
108
|
+
unconfirmed Stars/Premium classification, no duplicates and redacted details.
|
|
109
|
+
Tests consume `../contract/fixtures.json` with synthetic credentials, mocked
|
|
110
|
+
transport and no real purchases. Run a secret scan and inspect gem contents
|
|
111
|
+
before releasing. Publish only with verified RubyGems ownership/MFA or trusted
|
|
112
|
+
publishing. `gem push` is a separate authorized release action; a local gem
|
|
113
|
+
build is not a publication. Verify installation from RubyGems after publishing.
|
|
114
|
+
|
|
115
|
+
[Docs](https://usnuz.github.io/fragment-donor-sdk/)
|
|
116
|
+
· [Source](https://github.com/usnuz/fragment-donor-sdk/tree/main/ruby)
|
|
117
|
+
· [Changelog](CHANGELOG.md) · [MIT license](LICENSE)
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "time"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module FragmentDonor
|
|
9
|
+
VERSION = "0.1.0"
|
|
10
|
+
DEFAULT_BASE_URL = "https://fragment.donor.uz"
|
|
11
|
+
|
|
12
|
+
class Error < StandardError
|
|
13
|
+
attr_reader :status, :code, :retry_after, :details
|
|
14
|
+
|
|
15
|
+
def initialize(message, status: nil, code: nil, retry_after: nil, details: nil, outcome_unknown: false)
|
|
16
|
+
super(message)
|
|
17
|
+
@status, @code, @retry_after = status, code, retry_after
|
|
18
|
+
@details = details
|
|
19
|
+
@outcome_unknown = outcome_unknown
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# False is not a rejection or idempotency guarantee.
|
|
23
|
+
def outcome_unknown? = @outcome_unknown
|
|
24
|
+
def mark_outcome_unknown! = @outcome_unknown = true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class ValidationError < Error; end
|
|
28
|
+
class APIError < Error; end
|
|
29
|
+
class RateLimitError < APIError; end
|
|
30
|
+
class UnavailableError < APIError; end
|
|
31
|
+
class TimeoutError < Error; end
|
|
32
|
+
class NetworkError < Error; end
|
|
33
|
+
class MalformedResponseError < Error; end
|
|
34
|
+
class PurchaseOutcomeUnknownError < APIError; end
|
|
35
|
+
|
|
36
|
+
class Credentials
|
|
37
|
+
attr_reader :mnemonic, :cookie, :wallet_version, :wallet_address, :provider_key, :proxy, :user_agent
|
|
38
|
+
|
|
39
|
+
def initialize(mnemonic: nil, cookie: nil, wallet_version: "auto", wallet_address: nil, provider_key: nil, proxy: nil, user_agent: nil)
|
|
40
|
+
@mnemonic, @cookie, @wallet_version, @wallet_address = mnemonic, cookie, wallet_version, wallet_address
|
|
41
|
+
@provider_key, @proxy, @user_agent = provider_key, proxy, user_agent
|
|
42
|
+
freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def inspect = "#<FragmentDonor::Credentials [REDACTED]>"
|
|
46
|
+
alias to_s inspect
|
|
47
|
+
def to_json(*) = { credentials: "[REDACTED]" }.to_json
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Unknown fields remain in extra; decimal balances remain String values.
|
|
51
|
+
UserInfo = Struct.new(:ok, :username, :is_premium, :extra, keyword_init: true) do
|
|
52
|
+
def inspect = "#<FragmentDonor::UserInfo response=[REDACTED]>"
|
|
53
|
+
alias to_s inspect
|
|
54
|
+
end
|
|
55
|
+
Purchase = Struct.new(:ok, :data, :extra, keyword_init: true) do
|
|
56
|
+
def inspect = "#<FragmentDonor::Purchase response=[REDACTED]>"
|
|
57
|
+
alias to_s inspect
|
|
58
|
+
end
|
|
59
|
+
WalletBalance = Struct.new(:ok, :address, :ton, :usdt_ton, :extra, keyword_init: true) do
|
|
60
|
+
def inspect = "#<FragmentDonor::WalletBalance response=[REDACTED]>"
|
|
61
|
+
alias to_s inspect
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Request = Struct.new(:method, :url, :headers, :body, keyword_init: true) do
|
|
65
|
+
def inspect = "#<FragmentDonor::Request #{method} [REDACTED]>"
|
|
66
|
+
alias to_s inspect
|
|
67
|
+
end
|
|
68
|
+
HTTPResponse = Struct.new(:status, :headers, :body, keyword_init: true)
|
|
69
|
+
|
|
70
|
+
class NetHTTPTransport
|
|
71
|
+
def initialize(connect_timeout:, request_timeout:)
|
|
72
|
+
@connect_timeout, @request_timeout = connect_timeout, request_timeout
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def call(request)
|
|
76
|
+
uri = URI(request.url)
|
|
77
|
+
# nil disables ambient proxy discovery. Proxy header is a Fragment provider
|
|
78
|
+
# parameter, not a proxy for transmitting wallet credentials to this API.
|
|
79
|
+
http = Net::HTTP.new(uri.host, uri.port, nil)
|
|
80
|
+
http.use_ssl = uri.scheme == "https"
|
|
81
|
+
http.open_timeout = @connect_timeout
|
|
82
|
+
http.read_timeout = @request_timeout
|
|
83
|
+
http.write_timeout = @request_timeout
|
|
84
|
+
http.max_retries = 0 # SDK alone decides bounded GET retry; never hidden POST retry.
|
|
85
|
+
klass = request.method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
|
|
86
|
+
http_request = klass.new(uri.request_uri, request.headers)
|
|
87
|
+
http_request.body = request.body if request.body
|
|
88
|
+
response = http.request(http_request)
|
|
89
|
+
# Net::HTTP does not follow Location. Never implement implicit redirects.
|
|
90
|
+
HTTPResponse.new(status: response.code.to_i, headers: response.each_header.to_h, body: response.body.to_s)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def inspect = "#<FragmentDonor::NetHTTPTransport>"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
class Client
|
|
97
|
+
USERNAME = /\A@?[A-Za-z][A-Za-z0-9_]{3,31}\z/
|
|
98
|
+
WALLET_VERSIONS = %w[auto v5r1 v4r2 v3r2].freeze
|
|
99
|
+
PAYMENT_METHODS = %w[usdt_ton ton].freeze
|
|
100
|
+
|
|
101
|
+
def initialize(base_url: DEFAULT_BASE_URL, credentials: Credentials.new, connect_timeout: 5, request_timeout: 30,
|
|
102
|
+
read_retries: 0, automatic_wait: false, max_wait: 60, transport: nil, sleeper: nil, clock: nil, allow_local_http: false)
|
|
103
|
+
@base = URI(base_url)
|
|
104
|
+
loopback = %w[localhost 127.0.0.1 ::1].include?(@base.host)
|
|
105
|
+
valid_scheme = @base.scheme == "https" || (allow_local_http && @base.scheme == "http" && loopback)
|
|
106
|
+
unless valid_scheme && @base.host && !@base.userinfo && !@base.query && !@base.fragment
|
|
107
|
+
raise ValidationError, "HTTPS base URL without credentials/query is required"
|
|
108
|
+
end
|
|
109
|
+
valid_timeout = ->(value) { value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite? && value.respond_to?(:positive?) && value.positive? }
|
|
110
|
+
unless read_retries.is_a?(Integer) && (0..2).cover?(read_retries) && valid_timeout.call(connect_timeout) && valid_timeout.call(request_timeout) && valid_timeout.call(max_wait) && max_wait <= 60
|
|
111
|
+
raise ValidationError, "Invalid retry/timeout configuration"
|
|
112
|
+
end
|
|
113
|
+
@credentials, @read_retries, @automatic_wait, @max_wait = credentials, read_retries, automatic_wait, max_wait
|
|
114
|
+
@transport = transport || NetHTTPTransport.new(connect_timeout: connect_timeout, request_timeout: request_timeout)
|
|
115
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
116
|
+
@clock = clock || -> { Time.now }
|
|
117
|
+
rescue URI::InvalidURIError
|
|
118
|
+
raise ValidationError.new("Invalid base URL"), cause: nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def inspect = "#<FragmentDonor::Client credentials=[REDACTED]>"
|
|
122
|
+
alias to_s inspect
|
|
123
|
+
def to_json(*) = { client: "[REDACTED]" }.to_json
|
|
124
|
+
|
|
125
|
+
def get_user_info(username)
|
|
126
|
+
validate_username(username)
|
|
127
|
+
data = request("GET", "/get-user-info/", query: { username: username })
|
|
128
|
+
unless data["username"].nil? || data["username"].is_a?(String)
|
|
129
|
+
raise MalformedResponseError, "Unexpected username type"
|
|
130
|
+
end
|
|
131
|
+
if data.key?("is_premium") && ![true, false].include?(data["is_premium"])
|
|
132
|
+
raise MalformedResponseError, "Unexpected is_premium type"
|
|
133
|
+
end
|
|
134
|
+
UserInfo.new(ok: true, username: data["username"], is_premium: data["is_premium"], extra: extras(data, %w[ok username is_premium]))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def buy_stars(username, amount, payment_method: nil)
|
|
138
|
+
validate_username(username)
|
|
139
|
+
unless amount.is_a?(Integer) && (50..1_000_000).cover?(amount)
|
|
140
|
+
raise ValidationError, "Stars amount must be an integer from 50 to 1000000"
|
|
141
|
+
end
|
|
142
|
+
buy("/buy-stars/", username, "amount", amount, payment_method)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def buy_premium(username, duration, payment_method: nil)
|
|
146
|
+
validate_username(username)
|
|
147
|
+
unless duration.is_a?(Integer) && [3, 6, 12].include?(duration)
|
|
148
|
+
raise ValidationError, "Premium duration must be 3, 6 or 12 months"
|
|
149
|
+
end
|
|
150
|
+
buy("/buy-premium/", username, "duration", duration, payment_method)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Backend accepts GET and POST; SDK deliberately uses GET.
|
|
154
|
+
def wallet_balance
|
|
155
|
+
data = request("GET", "/wallet-balance/", wallet: true)
|
|
156
|
+
unless %w[address ton usdt_ton].all? { |key| data[key].is_a?(String) }
|
|
157
|
+
raise MalformedResponseError, "Wallet address and decimal balances must be strings"
|
|
158
|
+
end
|
|
159
|
+
WalletBalance.new(ok: true, address: data["address"], ton: data["ton"], usdt_ton: data["usdt_ton"], extra: extras(data, %w[ok address ton usdt_ton]))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def validate_username(username)
|
|
165
|
+
raise ValidationError, "Invalid username" unless username.is_a?(String) && USERNAME.match?(username)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def buy(path, username, field, quantity, payment_method)
|
|
169
|
+
if payment_method && !PAYMENT_METHODS.include?(payment_method)
|
|
170
|
+
raise ValidationError, "payment_method must be usdt_ton or ton"
|
|
171
|
+
end
|
|
172
|
+
form = { "username" => username, field => quantity.to_s }
|
|
173
|
+
form["payment_method"] = payment_method if payment_method
|
|
174
|
+
data = request("POST", path, form: form, wallet: true, purchase: true)
|
|
175
|
+
Purchase.new(ok: true, data: data["data"], extra: extras(data, %w[ok data]))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def extras(data, known)
|
|
179
|
+
data.reject { |key, _| known.include?(key) }.freeze
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def headers(wallet, purchase)
|
|
183
|
+
result = { "Accept" => "application/json" }
|
|
184
|
+
return result unless wallet
|
|
185
|
+
|
|
186
|
+
if @credentials.mnemonic.to_s.strip.empty?
|
|
187
|
+
raise ValidationError, "Mnemonic is required for wallet/purchase operations"
|
|
188
|
+
end
|
|
189
|
+
if purchase && @credentials.cookie.to_s.strip.empty?
|
|
190
|
+
raise ValidationError, "Cookie is required for purchases"
|
|
191
|
+
end
|
|
192
|
+
version = @credentials.wallet_version || "auto"
|
|
193
|
+
raise ValidationError, "Invalid Wallet-Version" unless WALLET_VERSIONS.include?(version)
|
|
194
|
+
|
|
195
|
+
result["Mnemonic"] = @credentials.mnemonic
|
|
196
|
+
result["Wallet-Version"] = version
|
|
197
|
+
{ "Wallet-Address" => @credentials.wallet_address, "Api-Key" => @credentials.provider_key }.each do |key, value|
|
|
198
|
+
result[key] = value unless value.to_s.empty?
|
|
199
|
+
end
|
|
200
|
+
if purchase
|
|
201
|
+
result["Cookie"] = @credentials.cookie
|
|
202
|
+
{ "Proxy" => @credentials.proxy, "User-Agent" => @credentials.user_agent }.each do |key, value|
|
|
203
|
+
result[key] = value unless value.to_s.empty?
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
if result.values.any? { |value| !value.is_a?(String) || value.match?(/[\r\n]/) }
|
|
207
|
+
raise ValidationError, "Invalid header value"
|
|
208
|
+
end
|
|
209
|
+
result
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def request(method, path, query: nil, form: nil, wallet: false, purchase: false)
|
|
213
|
+
uri = @base.dup
|
|
214
|
+
uri.path = @base.path.delete_suffix("/") + path
|
|
215
|
+
uri.query = URI.encode_www_form(query) if query
|
|
216
|
+
request_headers = headers(wallet, purchase)
|
|
217
|
+
request_headers["Content-Type"] = "application/x-www-form-urlencoded" if form
|
|
218
|
+
request_data = Request.new(method: method, url: uri.to_s, headers: request_headers.freeze, body: form && URI.encode_www_form(form))
|
|
219
|
+
attempts, limit = 0, purchase ? 0 : @read_retries
|
|
220
|
+
loop do
|
|
221
|
+
begin
|
|
222
|
+
response = safe_transport(request_data)
|
|
223
|
+
return parse_response(response, purchase)
|
|
224
|
+
rescue Error => error
|
|
225
|
+
if purchase && (error.is_a?(TimeoutError) || error.is_a?(NetworkError) || error.is_a?(MalformedResponseError) ||
|
|
226
|
+
(error.status && error.status >= 500 && error.code != "RATE_LIMIT_UNAVAILABLE") ||
|
|
227
|
+
(error.status && (300..399).cover?(error.status)))
|
|
228
|
+
error.mark_outcome_unknown!
|
|
229
|
+
end
|
|
230
|
+
raise error, cause: nil if attempts >= limit
|
|
231
|
+
wait = retry_delay(error, attempts)
|
|
232
|
+
raise error, cause: nil unless wait
|
|
233
|
+
@sleeper.call(wait)
|
|
234
|
+
attempts += 1
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def safe_transport(request_data)
|
|
240
|
+
@transport.call(request_data)
|
|
241
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error
|
|
242
|
+
raise TimeoutError.new("Transport timed out; sensitive details omitted"), cause: nil
|
|
243
|
+
rescue StandardError
|
|
244
|
+
raise NetworkError.new("Transport failed; sensitive details omitted"), cause: nil
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def parse_response(response, purchase)
|
|
248
|
+
status = response.status
|
|
249
|
+
begin
|
|
250
|
+
data = JSON.parse(response.body)
|
|
251
|
+
rescue JSON::ParserError
|
|
252
|
+
data = nil
|
|
253
|
+
end
|
|
254
|
+
data = nil unless data.is_a?(Hash)
|
|
255
|
+
wait = retry_after(response.headers, data || {})
|
|
256
|
+
if purchase && data && data["unconfirmed"] == true
|
|
257
|
+
message = %w[error reason info message].filter_map { |key| data[key] if data[key].is_a?(String) && !data[key].empty? }.first
|
|
258
|
+
message ||= "Purchase outcome unknown; reconcile manually and do not retry"
|
|
259
|
+
code = data["error_code"]
|
|
260
|
+
code = nil unless code.is_a?(String)
|
|
261
|
+
raise PurchaseOutcomeUnknownError.new(redact(message), status: status, code: code && redact(code), retry_after: wait,
|
|
262
|
+
details: safe_details(data), outcome_unknown: true), cause: nil
|
|
263
|
+
end
|
|
264
|
+
if !(200..299).cover?(status)
|
|
265
|
+
klass = case status
|
|
266
|
+
when 429 then RateLimitError
|
|
267
|
+
when 503 then UnavailableError
|
|
268
|
+
else APIError
|
|
269
|
+
end
|
|
270
|
+
message = data && %w[error reason info message].filter_map { |key| data[key] if data[key].is_a?(String) && !data[key].empty? }.first
|
|
271
|
+
message = "HTTP request failed" unless message.is_a?(String)
|
|
272
|
+
code = data && data["error_code"]
|
|
273
|
+
code = nil unless code.is_a?(String)
|
|
274
|
+
raise klass.new(redact(message), status: status, code: code && redact(code), retry_after: wait, details: data && safe_details(data)), cause: nil
|
|
275
|
+
end
|
|
276
|
+
unless data && [true, false].include?(data["ok"])
|
|
277
|
+
raise MalformedResponseError.new("Expected JSON object with boolean ok", status: status), cause: nil
|
|
278
|
+
end
|
|
279
|
+
if data["ok"] == false
|
|
280
|
+
message = %w[error reason info].filter_map { |key| data[key] if data[key].is_a?(String) && !data[key].empty? }.first
|
|
281
|
+
message = "API reported failure" unless message.is_a?(String)
|
|
282
|
+
raise APIError.new(redact(message), status: status, retry_after: wait, details: safe_details(data)), cause: nil
|
|
283
|
+
end
|
|
284
|
+
data
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def retry_after(headers, data)
|
|
288
|
+
hints = [data["retry_after"], data["flood_wait"]]
|
|
289
|
+
header = headers.find { |key, _| key.to_s.casecmp?("Retry-After") }&.last
|
|
290
|
+
if header
|
|
291
|
+
begin
|
|
292
|
+
hints << Float(header)
|
|
293
|
+
rescue ArgumentError, TypeError
|
|
294
|
+
begin
|
|
295
|
+
hints << Time.httpdate(header) - @clock.call
|
|
296
|
+
rescue ArgumentError, TypeError
|
|
297
|
+
# Ignore malformed retry headers; never expose raw header content.
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
parsed = hints.filter_map do |hint|
|
|
302
|
+
begin
|
|
303
|
+
number = Float(hint)
|
|
304
|
+
number if number.finite? && number >= 0 && number <= 31_536_000
|
|
305
|
+
rescue ArgumentError, TypeError
|
|
306
|
+
nil
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
parsed.max
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def retry_delay(error, attempt)
|
|
313
|
+
wait = 2**attempt
|
|
314
|
+
if error.is_a?(RateLimitError) || error.is_a?(UnavailableError)
|
|
315
|
+
return nil unless @automatic_wait
|
|
316
|
+
wait = error.retry_after || wait
|
|
317
|
+
elsif !error.is_a?(TimeoutError) && !error.is_a?(NetworkError) && !(error.status && (500..599).cover?(error.status))
|
|
318
|
+
return nil
|
|
319
|
+
end
|
|
320
|
+
wait <= @max_wait ? wait : nil
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def redact(message)
|
|
324
|
+
secrets = [@credentials.mnemonic, @credentials.mnemonic.to_s.split.join(" "), @credentials.cookie, @credentials.provider_key, @credentials.proxy]
|
|
325
|
+
begin
|
|
326
|
+
proxy = URI(@credentials.proxy.to_s)
|
|
327
|
+
secrets += [proxy.user, proxy.password].compact.map { |value| URI::DEFAULT_PARSER.unescape(value) }
|
|
328
|
+
rescue URI::InvalidURIError
|
|
329
|
+
# Invalid provider configuration still cannot be echoed as a raw secret.
|
|
330
|
+
end
|
|
331
|
+
@credentials.cookie.to_s.split(";").each do |part|
|
|
332
|
+
value = part.strip.split("=", 2)[1]
|
|
333
|
+
secrets += [value, value&.delete_prefix('"')&.delete_suffix('"')]
|
|
334
|
+
end
|
|
335
|
+
variants = secrets.compact.reject(&:empty?).flat_map do |value|
|
|
336
|
+
[value, URI::DEFAULT_PARSER.unescape(value), URI.encode_www_form_component(value)]
|
|
337
|
+
end
|
|
338
|
+
variants.reduce(message.to_s) { |text, secret| text.gsub(secret, "[REDACTED]") }
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def safe_details(value)
|
|
342
|
+
case value
|
|
343
|
+
when Hash
|
|
344
|
+
sensitive = %w[mnemonic seed cookie session stringsession password proxypassword proxy apikey providerkey authorization token]
|
|
345
|
+
value.to_h do |key, item|
|
|
346
|
+
normalized = key.to_s.downcase.delete("-_")
|
|
347
|
+
[key, sensitive.include?(normalized) ? "[REDACTED]" : safe_details(item)]
|
|
348
|
+
end.freeze
|
|
349
|
+
when Array then value.map { |item| safe_details(item) }.freeze
|
|
350
|
+
when String then redact(value)
|
|
351
|
+
else value
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: fragment-donor-sdk
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Fragment Donor SDK contributors
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: json
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.6'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '4'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '2.6'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '4'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: net-http
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '0.3'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '1'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '0.3'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '1'
|
|
52
|
+
- !ruby/object:Gem::Dependency
|
|
53
|
+
name: time
|
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '0.2'
|
|
59
|
+
- - "<"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '1'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0.2'
|
|
69
|
+
- - "<"
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '1'
|
|
72
|
+
description: Server-side Telegram Stars/Premium integration with typed responses,
|
|
73
|
+
exact decimal balances, safe error handling and no purchase retries.
|
|
74
|
+
executables: []
|
|
75
|
+
extensions: []
|
|
76
|
+
extra_rdoc_files: []
|
|
77
|
+
files:
|
|
78
|
+
- CHANGELOG.md
|
|
79
|
+
- LICENSE
|
|
80
|
+
- README.md
|
|
81
|
+
- lib/fragment_donor_sdk.rb
|
|
82
|
+
homepage: https://usnuz.github.io/fragment-donor-sdk/
|
|
83
|
+
licenses:
|
|
84
|
+
- MIT
|
|
85
|
+
metadata:
|
|
86
|
+
source_code_uri: https://github.com/usnuz/fragment-donor-sdk/tree/main/ruby
|
|
87
|
+
documentation_uri: https://usnuz.github.io/fragment-donor-sdk/
|
|
88
|
+
changelog_uri: https://github.com/usnuz/fragment-donor-sdk/blob/main/ruby/CHANGELOG.md
|
|
89
|
+
rubygems_mfa_required: 'true'
|
|
90
|
+
rdoc_options: []
|
|
91
|
+
require_paths:
|
|
92
|
+
- lib
|
|
93
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
94
|
+
requirements:
|
|
95
|
+
- - ">="
|
|
96
|
+
- !ruby/object:Gem::Version
|
|
97
|
+
version: '3.2'
|
|
98
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0'
|
|
103
|
+
requirements: []
|
|
104
|
+
rubygems_version: 3.6.9
|
|
105
|
+
specification_version: 4
|
|
106
|
+
summary: Independent no-service-auth Fragment Donor API SDK
|
|
107
|
+
test_files: []
|