diff --git a/Gemfile.lock b/Gemfile.lock index b65fba0..23a0cbf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: rail0 (1.2.0) + logger (~> 1.6) GEM remote: https://rubygems.org/ @@ -43,6 +44,7 @@ GEM http-2 (>= 1.1.3) keccak (1.3.3) konstructor (1.0.2) + logger (1.7.0) mini_portile2 (2.8.9) openssl (3.3.3) pkg-config (1.6.5) @@ -109,6 +111,7 @@ CHECKSUMS httpx (1.7.8) sha256=6d769465ed608287a272ba0e4700fc22cee6f0335d80bd5c2effaf7fb7bd2a3a keccak (1.3.3) sha256=970dcb1e78b8c3129ba2baff8a5baa5cebf391136db1f4018e8ccc9130794557 konstructor (1.0.2) sha256=fd6ac9eb1dadc1e520e06042aa2ef0122d6a070e06cde86db5a54c0c7bfe2e31 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289 openssl (3.3.3) sha256=d46902138f2987c13122fab826030a11c2bb9b8a16394215cbfc5062c5e2d335 pkg-config (1.6.5) sha256=33f9f81c5322983d22b439b8b672f27777b406fea23bfec74ff14bbeb42ec733 diff --git a/README.md b/README.md index 32aa3e1..a2a697d 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,33 @@ client.webhooks.event_callbacks(id, status: "failed") client.webhooks.delete(id) # 204 ``` +## Analytics (JWT) + +Account-scoped: the numbers are the merchant's own, so a buyer's account-less token is +refused with `account_required`. + +```ruby +client.analytics.summary(status: "captured") +# => { count: 42, disputed_count: 1 } + +# Volume needs BOTH token and chain_id — amounts in different tokens (or the same symbol +# on different chains) are different units, and summing them would produce a number that +# looks meaningful and isn't. +client.analytics.summary(token: usdc, chain_id: 84_532) +# => { count: 12, disputed_count: 0, volume: { token: "0x…", chain_id: 84532, total: "12000000" } } + +client.analytics.timeseries(interval: "day", from: "2026-07-01T00:00:00Z") +# => [{ bucket: "2026-07-01", count: 3 }, …] oldest first + +client.analytics.breakdown(by: "chain") +# => [{ chain_id: 84532, count: 9 }, …] by: token | chain | mode | status +``` + +All three take the same filters — `mode`, `status`, `token`, `chain_id`, `from`, `to` — so +the same question can be asked at three resolutions: one total, a series over time, a split +by dimension. Token and chain rows carry per-token volume; mode and status rows are counts +only, for the reason above. + ## Signing helpers (`Rail0::Signing`) Requires the `eth` gem. No private key ever leaves your process. @@ -297,18 +324,44 @@ client = Rail0::Client.new( ## Error handling -Non-2xx responses raise `Rail0::ApiError`: +Non-2xx responses raise `Rail0::ApiError`, carrying the gateway's code/title/detail +triple: ```ruby begin client.payments.capture(rail0_id, { signed_transaction: raw }) rescue Rail0::ApiError => e - e.status # 422 - e.error # "not_capturable" (machine-readable code) - e.message # human-readable description + e.status # 422 + e.error # "insufficient_token_balance" — branch on this, and only this + e.title # "Not enough balance" — a heading + e.detail # a sentence you can show a user verbatim (also e.message) + e.hint # this SDK's own extra advice, nil when it has none end ``` +**`error` is the only field to branch on.** It is the specific condition, read from the +gateway's `code` and falling back to the older `error` sub-code and then to `status` (the +wider family), so an older gateway still yields the most specific value it sent. + +`title` and `detail` come from the gateway's error catalogue, so the same condition always +reads the same way whichever endpoint surfaced it; `detail` is written to be shown to a +user as-is. + +`hint` (or `Rail0.describe_error(code)`) is this SDK's own advice — a *supplement* to +`detail`, present only for codes with a next step worth adding. The codes span four +families, and the last two are the ones most requests actually hit, neither raised by +RAIL0 itself: + +| Family | Examples | +| --- | --- | +| Request & state guards | `not_capturable`, `amount_exceeds_refundable`, `not_the_payee` | +| RAIL0 custom errors | `not_payee`, `already_captured`, `refund_expired` | +| Token reverts | `insufficient_token_balance`, `invalid_token_signature`, `authorization_already_used` | +| Broadcast rejections | `insufficient_gas_funds`, `nonce_too_low`, `replacement_underpriced` | + +A failed transaction carries the same triple as `error_code`, `error_title` and +`error_detail`, whether it reverted on-chain or was refused before broadcast. + ## Configuration ```ruby @@ -334,7 +387,8 @@ lib/rail0/ http_client.rb thin per-verb facade (get/post/put/patch/delete) over Request request.rb Rail0::Request — one HTTP call: retry, pagination, error mapping, logging default_logger.rb Rail0::LogEntry + Rail0::DefaultLogger (Logger subclass) for `logger:` - api_error.rb Rail0::ApiError + api_error.rb Rail0::ApiError (code/title/detail + #hint) + error_hints.rb Rail0.describe_error — per-code next steps, shared with the other SDKs signing.rb EIP-3009 + EIP-1559 signing (requires 'eth') stablecoins.rb stablecoin address registry types.rb generated Struct docs of the gateway schema (reference only) @@ -348,6 +402,7 @@ lib/rail0/ wallets.rb account-scoped wallet management (JWT) payments.rb payment lifecycle + disputes webhooks.rb webhook subscription management (JWT) + analytics.rb account-scoped payment analytics (JWT) query.rb shared query-string helper ``` diff --git a/lib/rail0.rb b/lib/rail0.rb index 9533091..92b9121 100644 --- a/lib/rail0.rb +++ b/lib/rail0.rb @@ -6,6 +6,7 @@ end require "rail0/version" +require "rail0/error_hints" require "rail0/api_error" require "rail0/default_logger" require "rail0/request" diff --git a/lib/rail0/api_error.rb b/lib/rail0/api_error.rb index 2b57c9d..0001d93 100644 --- a/lib/rail0/api_error.rb +++ b/lib/rail0/api_error.rb @@ -1,22 +1,39 @@ # frozen_string_literal: true module Rail0 - # Raised for non-2xx responses from the RAIL0 API. + # Raised for non-2xx responses from the RAIL0 gateway, mirroring the code/title/detail + # triple the gateway answers with. `detail` is written to be shown to a user; `hint` is + # this SDK's own advice, present only for codes worth adding a next step to. class ApiError < StandardError # @!attribute [r] status # @return [Integer] HTTP status code (e.g. 404, 409, 422). # @!attribute [r] error - # @return [String] Machine-readable error identifier (e.g. "PaymentNotFound"). - attr_reader :status, :error + # @return [String] The specific condition, and the only field to branch on — e.g. + # "not_capturable", "insufficient_token_balance", "insufficient_gas_funds". + # @!attribute [r] title + # @return [String, nil] Short label for the failure, e.g. "Not enough balance". + # @!attribute [r] detail + # @return [String, nil] One or two sentences fit to show a user verbatim. Also this + # exception's message. + attr_reader :status, :error, :title, :detail # @param status [Integer] # @param error [String] - # @param message [String] - def initialize(status, error, message) + # @param message [String] The detail; kept positional for compatibility. + # @param title [String, nil] + def initialize(status, error, message, title: nil) super(message) @status = status @error = error + @title = title + @detail = message freeze end + + # This SDK's actionable next step for the error, or nil when it has none. + # @return [String, nil] + def hint + Rail0.describe_error(error) + end end end diff --git a/lib/rail0/client.rb b/lib/rail0/client.rb index 91396e9..27e7475 100644 --- a/lib/rail0/client.rb +++ b/lib/rail0/client.rb @@ -10,6 +10,7 @@ require "resources/payments" require "resources/disputes" require "resources/webhooks" +require "resources/analytics" module Rail0 # Entry point for the RAIL0 SDK. @@ -40,8 +41,11 @@ class Client # @return [Resources::Disputes] Account-level dispute list (JWT). # @!attribute [r] webhooks # @return [Resources::Webhooks] Webhook subscription management (JWT). + # @!attribute [r] analytics + # @return [Resources::Analytics] Account-scoped payment analytics (JWT). attr_reader :auth, :chains, :tokens, :health, :payment_methods, - :wallets, :payments, :disputes, :webhooks + :wallets, :payments, :disputes, :webhooks, :analytics + # @param base_url [String] Base URL of the RAIL0 API, e.g. "https://api.rail0.xyz". # @param headers [Hash] Default headers merged into every request (e.g. Authorization). @@ -63,6 +67,7 @@ def initialize(base_url:, headers: {}, timeout: 30, logger: nil, max_retries: 0, @payments = Resources::Payments.new(http) @disputes = Resources::Disputes.new(http) @webhooks = Resources::Webhooks.new(http) + @analytics = Resources::Analytics.new(http) freeze end end diff --git a/lib/rail0/error_hints.rb b/lib/rail0/error_hints.rb new file mode 100644 index 0000000..0a9a656 --- /dev/null +++ b/lib/rail0/error_hints.rb @@ -0,0 +1,57 @@ +module Rail0 + # Actionable next steps per error code, supplementing the gateway's own `detail`. + # Shared source with the Go SDK (rail0.DescribeError), the TS SDK (describeError) and + # the CLI's hints — keep the four in step. + ERROR_HINTS = { + "amount_exceeds_capturable" => "amount is above the capturable balance — check capturableAmount on the payment", + "amount_exceeds_refundable" => "amount is above the refundable balance — check refundableAmount on the payment", + "not_capturable" => "the payment must be 'authorized' or 'partially_captured' to capture", + "not_voidable" => "void is only allowed while 'authorized' with nothing captured — use release for the remainder after a capture", + "not_releasable" => "release opens only after authorizationExpiry", + "not_refundable" => "nothing is refundable — the payment must be charged/captured and within the refund window", + "not_signable" => "the payment must be 'unsigned' to sign", + "already_signed" => "the payer signature is already stored — the payee can act now", + "no_signature" => "the payer has not signed yet", + "wrong_mode" => "this operation doesn't match the payment's mode (authorize vs charge)", + "already_disputed" => "a dispute is already open — close it first", + "not_disputed" => "there is no open dispute to close", + "nothing_to_dispute" => "a dispute needs a merchant-held (refundable) balance", + "transaction_not_overwritable" => "a transaction for this operation is already in flight — wait for it to settle", + "signer_mismatch" => "the signing key doesn't match the payment's payer/payee", + "config_hash_mismatch" => "the payment record and its on-chain deployment disagree — the payment cannot be operated as recorded", + "payment_not_on_chain" => "the contract has no record of this payment — its opening transaction may never have confirmed", + "unsupported_contract_version" => "the payment's RAIL0 deployment is newer or older than this gateway supports — upgrade the gateway", + "insufficient_token_balance" => "the paying wallet does not hold enough of the token — top it up and retry", + "invalid_token_signature" => "the EIP-3009 authorization did not recover to the paying wallet — wrong key, chain, token or amount", + "authorization_already_used" => "that EIP-3009 authorization was already spent or cancelled — each is single-use, create a fresh payment", + "authorization_not_yet_valid" => "the authorization's validAfter is still in the future", + "token_account_blocked" => "the token issuer has blocklisted one of the wallets in this transfer", + "token_paused" => "the token contract is paused by its issuer — no transfer can settle right now", + "insufficient_gas_funds" => "the sending wallet cannot cover gas — fund it with the chain's native token", + "nonce_too_low" => "a transaction with that nonce is already on-chain — re-prepare the operation", + "replacement_underpriced" => "another transaction with that nonce is pending and this one does not pay enough to replace it", + "gas_price_too_low" => "the fee is below what the node accepts — re-prepare to pick up current fees", + "already_known" => "the node already has this exact transaction — wait for it to confirm rather than resending", + "rpc_unavailable" => "no configured RPC endpoint answered — the transaction was not submitted", + "not_the_payee" => "only the payment's payee can do this — sign in with the merchant's wallet", + "not_the_payer" => "only the payment's payer can do this — sign in with the buyer's wallet", + "not_a_participant" => "only the payer and the payee can see or act on a payment", + "not_payee" => "only the merchant (payee) may do this", + "not_payer" => "only the buyer (payer) may do this", + "not_payer_or_payee" => "only the payer or the payee may do this", + "refund_expired" => "the refund window has closed (refundExpiry passed) — refund/dispute is no longer possible", + "authorization_not_expired" => "release opens only after authorizationExpiry — wait until it passes", + "already_captured" => "already (partially) captured — use release for the remainder, not void", + "token_not_accepted" => "the token isn't in this deployment's allowlist", + "payment_already_exists" => "a payment with this id already exists on-chain" + }.freeze + + # An actionable hint for a rail0 error code, or nil when the code is unknown. + # @param code [String, nil] + # @return [String, nil] + def self.describe_error(code) + return nil if code.nil? || code.to_s.empty? + + ERROR_HINTS[code.to_s] + end +end diff --git a/lib/rail0/request.rb b/lib/rail0/request.rb index 65c3a80..8283e87 100644 --- a/lib/rail0/request.rb +++ b/lib/rail0/request.rb @@ -45,7 +45,8 @@ def call unless response.is_a?(Net::HTTPSuccess) error_body = parse_error_body(response) - api_error = ApiError.new(response.code.to_i, error_code(error_body), error_message(error_body, response)) + api_error = ApiError.new(response.code.to_i, error_code(error_body), + error_message(error_body, response), title: error_body[:title]) logger.call(LogEntry.new( method: method.to_s.upcase, url: url, duration_ms: duration_ms, attempt: attempt, request_body: body, status: response.code.to_i, response_body: error_body, error: api_error @@ -118,11 +119,11 @@ def parse_error_body(response) end def error_code(body) - body[:status] || body[:code] || body[:error] + body[:code] || body[:error] || body[:status] end def error_message(body, response = nil) - body[:message] || body[:error] || (response && "HTTP #{response.code}") + body[:detail] || body[:message] || body[:error] || (response && "HTTP #{response.code}") end def elapsed_ms(start) diff --git a/lib/rail0/resources/analytics.rb b/lib/rail0/resources/analytics.rb new file mode 100644 index 0000000..c13fc45 --- /dev/null +++ b/lib/rail0/resources/analytics.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "query" + +module Rail0 + module Resources + # Account-scoped payment analytics (requires JWT — a buyer's account-less token is + # refused with `account_required`). Every endpoint takes the same filters, so the + # three views answer one question at different resolutions: a total, a series over + # time, a split by dimension. Volume is reported only when a query pins both `token` + # and `chain_id`, since amounts in different tokens are different units. + class Analytics + include Query + + FILTERS = %i[mode status token chain_id from to].freeze + + attr_reader :http + + def initialize(http) + @http = http + freeze + end + + # Totals for the account's payments. + # @param mode [String, nil] "authorize" or "charge". + # @param status [String, nil] A payment status, e.g. "captured". + # @param token [String, nil] Token address (0x…); with chain_id, unlocks volume. + # @param chain_id [Integer, nil] Chain ID. Pass nil or 0 for all chains. + # @param from [String, nil] ISO-8601 — payments created at/after this time. + # @param to [String, nil] ISO-8601 — payments created at/before this time. + # @return [Hash] count, disputed_count and (with token+chain_id) volume. + def summary(**filters) + http.get("/analytics/summary#{build_query(**only_filters(filters))}") + end + + # The account's payment count per time bucket, oldest first. + # @param interval [String, nil] "hour", "day", "week" or "month" (default "day"). + # @return [Array] bucket, count, and volume with token+chain_id. + def timeseries(interval: nil, **filters) + http.get("/analytics/timeseries#{build_query(**only_filters(filters).merge(interval: interval))}") + end + + # The account's payments aggregated by one dimension. + # @param by [String] Required — "token", "chain", "mode" or "status". + # @return [Array] One row per group; token and chain rows carry volume. + def breakdown(by:, **filters) + raise ArgumentError, "by is required (token, chain, mode or status)" if by.nil? || by.to_s.empty? + + http.get("/analytics/breakdown#{build_query(**only_filters(filters).merge(by: by))}") + end + + private + + def only_filters(filters) + picked = filters.slice(*FILTERS) + picked[:chain_id] = nil if picked[:chain_id] == 0 + picked + end + end + end +end diff --git a/lib/rail0/types.rb b/lib/rail0/types.rb index 1c758d7..787e850 100644 --- a/lib/rail0/types.rb +++ b/lib/rail0/types.rb @@ -46,19 +46,33 @@ module Types keyword_init: true ) - # Generic error envelope. `status` is a machine-readable code; additional context fields (e.g. - # `message`, `resource`, `param`, `errors`, `chain_id`) may also be present. + # Error envelope. Every error this API returns carries `code` (stable and machine-readable — the + # only field to branch on), `title` (a short label) and `detail` (one or two sentences fit to show a + # user verbatim). The wording comes from the gateway's error catalogue, so the same condition always + # reads the same way wherever it surfaces. `status` and `message` are the pre-code/title/detail + # names, derived from the same entry and kept for existing clients: `status` carries the family a + # client used to switch on (e.g. `forbidden`, `invalid_state`) where `code` is narrower, and + # `message` equals `detail`. Context fields may also be present (`resource`, `param`, `chain_id`, + # `token`, `payee`, `errors`). ApiErrorBody = Struct.new( - :status, # String - :message, # String + :code, # String + :title, # String + :detail, # String + :status, # String — Legacy: the error family, or the code itself when there is no wider family. + :message, # String — Legacy alias of `detail`. keyword_init: true ) + # Gateway liveness/readiness. Only the database gates the HTTP code (200 healthy, 503 when the DB is + # unreachable); Sidekiq is reported but never flips the code, since the API still serves synchronous + # requests when workers are down. `status` is the global signal: `ok` (all good), `degraded` (DB ok + # but Sidekiq not ok), `error` (DB down — the only 503). Health = Struct.new( :status, # String :api_version, # String :contract_version, # String :db, # String + :sidekiq, # Hash — Worker fleet health (does not gate liveness). :active_chains, # Integer :active_contracts, # Integer :timestamp, # String @@ -72,12 +86,15 @@ module Types keyword_init: true ) - # Issued after a successful SIWE verification. + # Issued after a successful SIWE verification. SIWE alone proves control of the address, so a token + # is issued even when the address is not registered to any account; in that case `account_id` and + # `name` are null (an account-less session, e.g. a buyer). Clients that require an account must + # treat a null `account_id` as not-allowed. Session = Struct.new( :token, # String — JWT bearer token. :address, # String — Resolved wallet address. - :account_id, # String - :name, # String — The account's human-readable name. + :account_id, # String — The account owning the signed-in wallet, or null for an account-less (e.g. buyer) session. + :name, # String — The account's human-readable name, or null for an account-less session. :expires_at, # String keyword_init: true ) @@ -92,12 +109,15 @@ module Types keyword_init: true ) - # Public accepted-token view. + # Public accepted-token view. The listing is not implicitly active-only (a payment references its + # token address forever, so a retired token must stay resolvable), so `active` tells a usable token + # from a retired one. Token = Struct.new( :chain_id, # Integer :symbol, # String :address, # String :decimals, # Integer + :active, # Boolean — False for a retired token: still resolvable for historical payments, but not usable for a new one. keyword_init: true ) @@ -187,8 +207,10 @@ module Types :payment_id, # String :operation, # String :status, # String - :error_code, # Decoded on-chain failure code (null unless status is "failed"): the RAIL0 custom error in snake_case (e.g. "not_payee"), or "revert" when the selector is unknown. The raw revert bytes are not exposed. - :error_message, # Human-readable form of error_code (e.g. "NotPayee"); null unless status is "failed". + :error_code, # Decoded failure code, null unless `status` is "failed". Same catalogue as an error body's `code`: a RAIL0 custom error (`not_payee`), a token-level revert (`insufficient_token_balance`, `invalid_token_signature`, `authorization_already_used`), a Solidity panic, or a rejection that stopped the broadcast before the chain saw it (`insufficient_gas_funds`, `nonce_too_low`). + :error_title, # String — Short label for `error_code`; null unless failed. + :error_detail, # String — Sentence explaining the failure; null unless failed. Carries the chain's own words when the revert was not one the gateway recognises. + :error_message, # Legacy alias of `error_detail`. :unsigned_transaction, :transaction_hash, :amount, diff --git a/rail0.gemspec b/rail0.gemspec index 9de2dec..d3ce932 100644 --- a/rail0.gemspec +++ b/rail0.gemspec @@ -14,6 +14,9 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.0" + # Not a default gem since Ruby 4.0; required by lib/rail0/default_logger.rb. + spec.add_dependency "logger", "~> 1.6" + spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE"] spec.require_paths = ["lib"] diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb new file mode 100644 index 0000000..3950538 --- /dev/null +++ b/spec/errors_spec.rb @@ -0,0 +1,102 @@ +RSpec.describe "error surface" do + let(:client) { Rail0::Client.new(base_url: BASE_URL) } + + def stub_error(status, body) + stub_request(:get, "#{BASE_URL}/payments/0xabc") + .to_return(status: status, body: body.to_json, headers: { "Content-Type" => "application/json" }) + end + + # The gateway answers code/title/detail. `code` is the specific condition and + # `status` the wider family it sits in, so reading `status` as the code — which this + # SDK used to do — hands a caller "invalid_state" where the gateway said + # "amount_exceeds_refundable", and hides exactly what went wrong. + it "prefers code over the status family, and carries title and detail" do + stub_error(422, { + code: "amount_exceeds_refundable", + title: "Amount above the refundable balance", + detail: "The amount is higher than the balance the merchant still holds for this payment.", + status: "invalid_state", + message: "The amount is higher than the balance the merchant still holds for this payment." + }) + + error = begin + client.payments.get("0xabc") + rescue Rail0::ApiError => e + e + end + + expect(error.error).to eq("amount_exceeds_refundable") + expect(error.title).to eq("Amount above the refundable balance") + expect(error.detail).to start_with("The amount is higher") + expect(error.message).to eq(error.detail) + expect(error.status).to eq(422) + end + + # An older gateway sends neither code nor detail: the specific condition arrives in + # `error` and the text in `message`. Both must still surface. + it "falls back to the pre-code/title/detail field names" do + stub_error(422, { status: "invalid_state", error: "not_capturable", message: "no capturable balance" }) + + error = begin + client.payments.get("0xabc") + rescue Rail0::ApiError => e + e + end + + expect(error.error).to eq("not_capturable") + expect(error.detail).to eq("no capturable balance") + expect(error.title).to be_nil + end + + it "falls back to the bare HTTP status when the body carries no text" do + stub_error(500, { code: "db_error" }) + + error = begin + client.payments.get("0xabc") + rescue Rail0::ApiError => e + e + end + + expect(error.error).to eq("db_error") + expect(error.message).to eq("HTTP 500") + end + + describe "hints" do + # A supplement to the gateway's detail, not a replacement — so it exists only for + # codes with a next step worth adding, and never invents one. + it "returns an actionable hint for a known code" do + expect(Rail0.describe_error("insufficient_gas_funds")).to include("native token") + expect(Rail0.describe_error("not_the_payee")).to include("merchant") + end + + it "returns nil for an unknown or empty code" do + expect(Rail0.describe_error("brand_new_condition")).to be_nil + expect(Rail0.describe_error(nil)).to be_nil + expect(Rail0.describe_error("")).to be_nil + end + + it "exposes the same hint through the raised error" do + stub_error(422, { code: "authorization_already_used", detail: "spent" }) + + error = begin + client.payments.get("0xabc") + rescue Rail0::ApiError => e + e + end + + expect(error.hint).to eq(Rail0.describe_error("authorization_already_used")) + expect(error.hint).to include("single-use") + end + + # The three SDKs and the CLI share this table; a code the gateway can return with no + # entry here is not a bug, but the families that DO have entries must stay complete — + # these are the ones a user hits most and cannot diagnose from the code alone. + it "covers the token-level and broadcast families" do + %w[insufficient_token_balance invalid_token_signature authorization_already_used + insufficient_gas_funds nonce_too_low replacement_underpriced already_known + config_hash_mismatch unsupported_contract_version].each do |code| + expect(Rail0.describe_error(code)).not_to be_nil, "#{code} has no hint" + end + end + end +end