rail0-sdk 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e046261654c0f0d93915dd0ac7003e78f224115748e697581cac6020c4ea4003
4
- data.tar.gz: f9fea94c35a6b996732e0bbdc538361a229134edaa4f52bc6a010e1dfd55b871
3
+ metadata.gz: 5d88d3da009d4c04853e8fb794eba655913763a1391c6e0bd6915bda6cb68d36
4
+ data.tar.gz: 5cc37022ce71f950c6b11dcf421902518b63caebfd54a13f78eaf909a7eef39c
5
5
  SHA512:
6
- metadata.gz: dcb35d580188aef4a5e6b36878e6e8b3da40dddd671a792eb3275f4baad286636ec6f00e6284d1a0263c6ec764d539e17d8c85ea1904b77f3f1326e8929174fd
7
- data.tar.gz: 592f3db30a8c9ecdc755f5b64e9dc0a69166ca73f6b51efcae2c348fb28e60d39b148b41b3cd54015012837c7a8987583d3bc6621f6ef82a4bca3e8b14381526
6
+ metadata.gz: 2512e0a842d7d76749dc4028a0c1d2590b7fe1dd4e95676572a39aa8c41fa9a882c2e4a534e85080f0fe8089c8001c6f2101ff4b9fb7ab9f4e2cc9d7e6d35d62
7
+ data.tar.gz: 7b569639525815722a586f308d45ebc67c964cbd4f6f1ae4c5bac4a8c5a731589b03dffebcfcdb375e3a265f9f1143b52162d5e8685de591e39a364de3257cf8
data/README.md CHANGED
@@ -78,7 +78,9 @@ payment = client.payments.create(
78
78
  rail0_id = payment[:rail0_id]
79
79
 
80
80
  # 2. Payer signs the EIP-3009 payload off-chain and deposits the signature.
81
- sig = Rail0::Signing.sign_payload(BUYER_PRIVATE_KEY, payment[:signing_payload])
81
+ # payment: is what the authorization is CHECKED against before the key signs it
82
+ # (see "What sign_signing_payload verifies").
83
+ sig = Rail0::Signing.sign_signing_payload(payment[:signing_payload], BUYER_PRIVATE_KEY, payment: payment)
82
84
  client.payments.sign(rail0_id, { signature: sig.to_hex })
83
85
 
84
86
  # 3. Payee prepares + broadcasts the on-chain authorize tx (signs it locally).
@@ -126,21 +128,54 @@ otherwise have that key sign anything — an `approve(spender, MAX)` on the toke
126
128
  `payment:` (the record from `payments.get`) and `operation:`, `sign_transaction` checks
127
129
  the transaction **before** the key touches it, against data you already hold (no extra
128
130
  request): `value` is zero (every RAIL0 operation is value-0), `chain_id` is the
129
- payment's chain, `from` is your key's address, `to` is the payment's `rail0_contract`,
131
+ payment's chain, `from` is your key's address, `to` is an address and is the payment's
132
+ `rail0_contract` (an empty or missing `to` — which would sign a contract creation — is
133
+ always refused, even by the deprecated blind form),
130
134
  the 4-byte selector is the RAIL0 function for `operation:` (the selectors are derived
131
135
  from the contract's function signatures, `Rail0::TxVerify::SELECTORS`, and pinned
132
136
  against its ABI in the spec suite), the first calldata word is the payment's `rail0_id`,
133
137
  and — for `capture` and `refund` — the amount word equals `amount:` scaled by the
134
138
  payment's `decimals`. The first mismatch raises `Rail0::TxVerify::RefusedSignature`
135
139
  ("refusing to sign: …", naming the field) and no signature is produced. It fails closed:
136
- a capture/refund with no `amount:`, or a payment with unknown `decimals`, is refused
137
- rather than skipped. `to` is a consistency check only — the address comes from the same
140
+ a capture/refund with no `amount:`, or a payment with no `rail0_contract` or unknown
141
+ `decimals`, is refused rather than skipped. `to` is a consistency check only — the address comes from the same
138
142
  gateway — which is why the calldata binding to this payment's id and operation is what
139
143
  closes a swapped transaction. Calling `sign_transaction` without `payment:` still signs
140
144
  blindly and is **deprecated** (a `:deprecated`-category warning; shown with
141
145
  `-W:deprecated`). `Rail0::TxVerify.verify` runs the check on its own, for a signer that
142
146
  is not a local key.
143
147
 
148
+ **What `sign_signing_payload` verifies, and why.** The EIP-3009 authorization the payer
149
+ signs after `create` (and the payee signs in refund phase 1) is the more dangerous of the
150
+ two artifacts: the **token** honours it on its own, with no RAIL0 involvement, for up to
151
+ its `value` — so an endpoint that answered with `{from: you, to: attacker, value: your
152
+ whole balance}` used to get exactly that signed. `sign_signing_payload(signing_payload,
153
+ key, payment:, amount: nil)` checks it first. The typed-data envelope: `primaryType` is
154
+ `ReceiveWithAuthorization` (authorize/charge on v1.4.0+ deployments, and every refund) or
155
+ `TransferWithAuthorization` (authorize/charge on v1.1.0–v1.3.x); `types[primaryType]` is
156
+ **exactly** the EIP-3009 struct — `address from, address to, uint256 value, uint256
157
+ validAfter, uint256 validBefore, bytes32 nonce`, same names, types and order;
158
+ `types.EIP712Domain`, when present, is exactly `string name, string version, uint256
159
+ chainId, address verifyingContract`; and `types` defines nothing else (a local key hashes
160
+ with fixed typehashes, but a browser wallet hashes whatever `types` says, so this is what
161
+ lets `PayloadVerify.verify` guard an `eth_signTypedData_v4` call). Then, against data you
162
+ already hold: `message.from` is your key's address, `domain.chainId` the payment's `chain_id`,
163
+ `domain.verifyingContract` the payment's `token`, `message.to` its `rail0_contract`, and
164
+ `message.value` **exactly** the amount — `amount:` scaled by the payment's `decimals` when
165
+ you pass one (a refund), otherwise the payment's own base-unit `amount` (a created
166
+ payment). The first mismatch raises `Rail0::TxVerify::RefusedSignature` ("refusing to
167
+ sign: …", naming the field) and nothing is signed. It fails closed: a payment record
168
+ missing `chain_id`, `token`, `rail0_contract` or an amount to compare is refused, and so
169
+ are unknown `decimals` and an amount finer than the token's. The payload is still signed
170
+ verbatim (typehash from its own `primaryType`). Not checked, as in the CLI: the validity
171
+ window and `message.nonce` (recomputing it needs the deployment's `VERSION()`, which no
172
+ payment response carries), and `to` and the decimals come from the same gateway. The
173
+ residual is an authorization for the right token, chain and signer, for exactly the
174
+ amount you asked. `sign_payload(key, signing_payload)` without `payment:` still signs
175
+ blindly and is **deprecated** (`:deprecated`-category warning); with `payment:` it is
176
+ `sign_signing_payload`. `Rail0::PayloadVerify.verify(signing_payload, payment:, signer:,
177
+ amount: nil)` runs the check on its own.
178
+
144
179
  Wallets that sign **and** broadcast in one step (e.g. MetaMask via
145
180
  `eth_sendTransaction`) skip steps 2–3 and report the hash instead:
146
181
 
@@ -211,8 +246,8 @@ auth = client.auth.login(private_key: "0x…", domain: "api.rail0.xyz")
211
246
  # admin is always a boolean (verify too): true only for an account holding the
212
247
  # operator grant, false otherwise (visibility only). account_id/name are nil for an
213
248
  # account-less session.
214
- # login embeds chain_id 1 by default; pass chain_id: to match a gateway whose
215
- # SIWE_CHAIN_ID policy differs (e.g. login(private_key:, domain:, chain_id: 5042002)).
249
+ # login embeds chain_id 1 by default; the gateway also accepts the id of any chain it
250
+ # has active (GET /blockchains), e.g. login(private_key:, domain:, chain_id: 5042002).
216
251
 
217
252
  authed = Rail0::Client.new(
218
253
  base_url: "https://api.rail0.xyz",
@@ -488,8 +523,11 @@ transactions, so a bulk action can skip busy rows without a `get(id)` each.
488
523
 
489
524
  ```ruby
490
525
  # Phase 1 — amount only → returns a signing payload for the payee to sign.
526
+ # The authorization is checked against the payment and the refund amount
527
+ # (payment = client.payments.get(rail0_id)) before the key signs it.
491
528
  p1 = client.payments.refund_prepare(rail0_id, amount: "20.00")
492
- sig = Rail0::Signing.sign_payload(MERCHANT_PRIVATE_KEY, p1[:signing_payload])
529
+ sig = Rail0::Signing.sign_signing_payload(p1[:signing_payload], MERCHANT_PRIVATE_KEY,
530
+ payment: payment, amount: "20.00")
493
531
 
494
532
  # Phase 2 — amount + signature → returns the unsigned on-chain tx.
495
533
  p2 = client.payments.refund_prepare(rail0_id, amount: "20.00", signature: sig.to_hex)
@@ -795,21 +833,29 @@ Requires the `eth` gem. No private key ever leaves your process.
795
833
 
796
834
  | Method | Use |
797
835
  |--------|-----|
798
- | `sign_payload(signer, signing_payload)` | Sign the EIP-3009 payload from a create/refund response — the recommended path, and the only one that follows the gateway across contract versions |
836
+ | `sign_signing_payload(signing_payload, signer, payment:, amount: nil)` | Verify the EIP-3009 payload from a create/refund response against the payment, then sign it verbatim — the recommended path, and the only one that follows the gateway across contract versions |
837
+ | `sign_payload(signer, signing_payload, payment: nil, amount: nil)` | The same signer; with `payment:` it is `sign_signing_payload`, without it signs blindly (**deprecated**) |
838
+ | `Rail0::PayloadVerify.verify(signing_payload, payment:, signer:, amount: nil)` | The payload pre-sign check on its own — run it before handing the payload to a browser wallet |
799
839
  | `sign_transaction(unsigned_transaction, key, payment:, operation:, amount: nil)` | Verify a prepare step's unsigned EIP-1559 transaction against the payment, then sign it; returns the 0x raw tx. Without `payment:` it signs blindly (**deprecated**) |
800
840
  | `Rail0::TxVerify.verify(unsigned_transaction, payment:, operation:, amount: nil, signer: nil)` | The pre-sign check on its own — for a signer that is not a local key |
801
841
  | `sign_transfer_with_authorization(signer, domain, params)` | Raw EIP-3009 `TransferWithAuthorization` signer, for talking to a token directly |
802
842
 
803
843
  ```ruby
804
844
  require "rail0/signing"
805
- sig = Rail0::Signing.sign_payload(BUYER_PRIVATE_KEY, payment[:signing_payload])
806
- client.payments.sign(rail0_id, { signature: sig.to_hex })
845
+ payment = client.payments.create(chain_id: 84532, mode: "authorize", amount: "50.00", token: TOKEN,
846
+ payer: BUYER, payee: MERCHANT)
847
+ sig = Rail0::Signing.sign_signing_payload(payment[:signing_payload], BUYER_PRIVATE_KEY, payment: payment)
848
+ client.payments.sign(payment[:rail0_id], { signature: sig.to_hex })
849
+ # A hostile payload — another `to`, a larger `value`, another token — raises instead:
850
+ # Rail0::TxVerify::RefusedSignature: refusing to sign: the authorization is for
851
+ # 999999999999 base units but you asked for 50000000
807
852
  ```
808
853
 
809
- **The gateway builds the payload; you sign it verbatim.** `sign_payload` takes the
854
+ **The gateway builds the payload; you sign it verbatim.** `sign_signing_payload` takes the
810
855
  typehash from the payload's `primaryType` and every field from its `message` — only
811
856
  the gateway knows which contract version a payment lives on, and the version selects
812
- the typehash, the EIP-712 domain and the field layout. An unrecognised `primaryType`
857
+ the typehash and the EIP-712 domain (the field layout is EIP-3009's own, the same on
858
+ every version, which is why the check pins it). An unrecognised `primaryType`
813
859
  raises rather than falling back, because a guessed typehash yields a *valid*
814
860
  signature over the wrong digest, which fails only on-chain, after gas.
815
861
 
@@ -818,20 +864,24 @@ digest from a payment record. They raise with a pointer here.
818
864
 
819
865
  ### Signing without a raw key
820
866
 
821
- `sign_payload` and `sign_transfer_with_authorization` accept a private-key String,
822
- an `Eth::Key`, or **any object responding to `#sign(digest)`** that returns a
823
- 65-byte hex signature — a KMS or HSM client, a remote signer, a hardware-wallet
867
+ `sign_signing_payload`, `sign_payload` and `sign_transfer_with_authorization` accept a
868
+ private-key String, an `Eth::Key`, or **any object responding to `#sign(digest)`** that
869
+ returns a 65-byte hex signature — a KMS or HSM client, a remote signer, a hardware-wallet
824
870
  bridge. The SDK builds the EIP-712 digest and hands only that over, so the secret
825
871
  need never be materialised as a String in your process:
826
872
 
827
873
  ```ruby
828
874
  class KmsSigner
829
875
  def sign(digest) = MyKms.sign(key_id: KEY_ID, digest: digest) # -> "0x…" (65 bytes)
876
+ def address = KMS_KEY_ADDRESS # what message.from is checked against
830
877
  end
831
878
 
832
- sig = Rail0::Signing.sign_payload(KmsSigner.new, payment[:signing_payload])
879
+ sig = Rail0::Signing.sign_signing_payload(payment[:signing_payload], KmsSigner.new, payment: payment)
833
880
  ```
834
881
 
882
+ The checked form needs the signer's address to compare with the authorization's `from`,
883
+ so a custom signer must also respond to `#address` (an `ArgumentError` says so otherwise).
884
+
835
885
  `sign_transaction` is narrower on purpose: `Eth::Tx#sign` derives an EIP-155 `v`
836
886
  from the chain id, so it needs a full `Eth::Key` (a String or an `Eth::Key`), not a
837
887
  bare digest signer.
@@ -977,6 +1027,8 @@ lib/rail0/
977
1027
  backoff.rb Rail0::Backoff — retry delays and 429 jitter
978
1028
  webhook_signature.rb Rail0::WebhookSignature — delivery verification
979
1029
  signing.rb EIP-3009 + EIP-1559 signing (requires 'eth')
1030
+ tx_verify.rb Rail0::TxVerify — pre-sign check of an unsigned transaction
1031
+ payload_verify.rb Rail0::PayloadVerify — pre-sign check of an EIP-3009 signing payload
980
1032
  stablecoins.rb stablecoin address registry
981
1033
  dispute_reasons.rb generated Rail0::DisputeReasons — the dispute-reason dictionary
982
1034
  types.rb generated Struct docs of the gateway schema (reference only)
@@ -23,11 +23,12 @@ module Rail0
23
23
  "signer_mismatch" => "the signature recovers to a different wallet than the one that must sign — the payment's payer/payee, or at POST /claim the wallet the onboarding request names",
24
24
  # The SIWE BINDING failures, split out of signer_mismatch so a failed login says
25
25
  # WHICH part of the proof did not bind (#216). None names the server's own
26
- # expectation: that endpoint is unauthenticated, so echoing the allow-list or the
27
- # expected chain id would turn every hint into a probe.
26
+ # expectation: that endpoint is unauthenticated, so echoing the allow-list would
27
+ # turn every hint into a probe. siwe_chain_mismatch may point at GET /blockchains:
28
+ # the accepted chain ids (1 plus every active catalog chain) are public there anyway.
28
29
  "siwe_domain_not_allowed" => "sign with the origin the front-end is served from, and have it added to the gateway's SIWE domain allow-list",
29
30
  "siwe_uri_mismatch" => "the message's uri host must equal its own domain",
30
- "siwe_chain_mismatch" => "use the chain id the client library sends - this login is off-chain and nominal",
31
+ "siwe_chain_mismatch" => "sign with chain id 1 (what the SDK's signing helpers send) or the id of a chain the gateway has active (GET /blockchains) - usually a hand-built message or a wallet on an unsupported or deactivated network",
31
32
  "siwe_proof_expired" => "get a fresh nonce and sign again",
32
33
  # Address-wide, not one token: "sign in again", not "that token is dead".
33
34
  "sessions_revoked" => "every session issued before this address's revoke-all cutoff is refused - sign in again",
@@ -79,6 +80,20 @@ module Rail0
79
80
  "unknown_token" => "the token isn't configured on this chain",
80
81
  "no_active_contract" => "no active RAIL0 contract on that chain",
81
82
  "missing_param" => "a required parameter is missing from the request",
83
+ # Ported from rail0-go / rail0-ts, which carried these five and this table did not.
84
+ "validation_failed" => "a parameter is invalid — the error detail names the offending field",
85
+ "invalid_sort" => "that field cannot be sorted on — the error detail names the allowed set",
86
+ "rate_limited" => "rate limit reached — wait for Retry-After (the time left in the window) and retry",
87
+ "event_not_found" => "the reported transaction mined but did not perform this operation on this payment — nothing moved; check the hash and prepare the operation again",
88
+ "submission_timeout" => "the transaction was not mined in time and was recorded as failed — if it mines later, re-report its hash via the operation's submitted endpoint",
89
+ # From the gateway's error catalog (lib/rail0/error_catalog.rb).
90
+ "invalid_signature" => "the signature could not be recovered — it is malformed, truncated or has a bad recovery byte; send the 65-byte 0x hex (r || s || v) the signer produced",
91
+ "nonce_used" => "that sign-in nonce was already consumed — request a fresh one (auth.nonce); each is single-use",
92
+ "siwe_purpose_mismatch" => "the signed message's statement is for a different flow — login, wallet registration, account claim and revoke-all each need their own exact statement",
93
+ "transaction_mismatch" => "the submitted transaction's target, calldata, value or chain differs from the one this operation prepared — sign the prepare's unsigned_transaction unmodified",
94
+ "signing_payload_unavailable" => "the gateway could not build the payer's authorization payload because a chain read failed — retry in a moment (payments.get returns it once available)",
95
+ "queue_unavailable" => "the gateway's job queue could not be reached, so nothing was enqueued — retry in a moment",
96
+ "webhook_limit_reached" => "this account already holds the most webhook subscriptions it may have — delete one, or add topics to an existing subscription",
82
97
  # Onboarding: POST /signup and POST /claim (the gateway's onboarding flow).
83
98
  # The /admin/onboardings codes (onboarding_not_found, onboarding_completed) are left
84
99
  # out: those routes are the console's, not the SDK's. The link codes never say which
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tx_verify"
4
+
5
+ module Rail0
6
+ # Pre-sign verification of a gateway-built EIP-712 / EIP-3009 +signing_payload+ —
7
+ # {Rail0::TxVerify}'s counterpart for the authorization rather than the transaction.
8
+ #
9
+ # It is the MORE dangerous artifact of the two. A malicious transaction costs gas and
10
+ # goes through RAIL0; an EIP-3009 authorization is a bearer instrument the TOKEN
11
+ # honours directly, with no RAIL0 involvement, for up to +message.value+. An endpoint
12
+ # that answered a refund prepare with +{from: <payee>, to: <attacker>, value: <the
13
+ # payee's whole balance>}+ used to get exactly that signed by {Signing.sign_payload},
14
+ # and could replay it against the token.
15
+ #
16
+ # Ported from the rail0 CLI's cmd/tx_verify.go (+verifySigningPayload+), which is the
17
+ # reference for the checks, their order and the wording. In order:
18
+ #
19
+ # 1. +message.from+ is the signing key's address — the one comparison whose right-hand
20
+ # side is derived locally from the key, so it holds whatever the endpoint claims;
21
+ # 2. +domain.chainId+ is the payment's +chain_id+;
22
+ # 3. +domain.verifyingContract+ is the payment's +token+ (for EIP-3009 the domain IS
23
+ # the token: this stops an authorization minted against another token the signer
24
+ # happens to hold);
25
+ # 4. +message.to+ is the payment's +rail0_contract+ — a consistency check, the address
26
+ # comes from the same gateway;
27
+ # 5. +message.value+ is EXACTLY the amount asked for, in base units — not a ceiling:
28
+ # the gateway asks for the full amount of the operation.
29
+ #
30
+ # Before those, the typed-data envelope: +primaryType+ is one of the two EIP-3009
31
+ # authorization types, +types[primaryType]+ is EXACTLY the EIP-3009 field list (names,
32
+ # types, order), +types.EIP712Domain+ (when present) is exactly the token domain's
33
+ # field list, and +types+ defines nothing else. {Signing.sign_payload} hashes with
34
+ # fixed typehashes and would not notice, but a browser wallet hashes whatever +types+
35
+ # says — so a caller that verifies here and then hands the payload to
36
+ # +eth_signTypedData_v4+ needs it.
37
+ #
38
+ # The payload is still signed VERBATIM: the typehash comes from its own +primaryType+
39
+ # and the digest from its own message. This only refuses a message whose fields
40
+ # contradict what the caller asked for. Like the CLI, it does not check the
41
+ # validity window, or +message.nonce+ — recomputing the nonce needs the deployment's
42
+ # VERSION(), which no payment response carries. The bounded residual is an
43
+ # authorization for the RIGHT token, on the RIGHT chain, from the RIGHT signer, for
44
+ # EXACTLY the amount asked for.
45
+ #
46
+ # FAIL-CLOSED where the CLI is: the amount check never skips (no amount and no stored
47
+ # base-unit amount, or unknown decimals, refuses). The CLI skips a check whose
48
+ # expectation it could not resolve; here every expectation comes from the payment
49
+ # record the caller passes, which always carries chain_id, token and rail0_contract,
50
+ # so a record missing one of them refuses rather than skipping.
51
+ #
52
+ # Loaded by +rail0/signing+ (TxVerify needs the +eth+ gem), never by +require "rail0"+.
53
+ module PayloadVerify
54
+ # Verifies +signing_payload+ against +payment+, BEFORE any key touches it. Raises
55
+ # {Rail0::TxVerify::RefusedSignature} ("refusing to sign: …", naming the field) on
56
+ # the first mismatch; returns the payload, symbol-keyed, otherwise.
57
+ #
58
+ # @param signing_payload [Hash] the +signing_payload+ of a create, get or refund
59
+ # prepare response (string or symbol keys)
60
+ # @param payment [Hash] the payment record: +chain_id+, +token+, +rail0_contract+,
61
+ # and +amount+ (base units) or +decimals+ (to scale +amount:+)
62
+ # @param signer [String] the address of the key about to sign
63
+ # @param amount [String, nil] the human decimal you asked for (a refund's amount).
64
+ # Scaled by the payment's +decimals+. When nil, the payment's own stored +amount+
65
+ # (base units) is the expectation — what the payer of a created payment agrees to.
66
+ # @return [Hash] the payload, symbol-keyed
67
+ def self.verify(signing_payload, payment:, signer:, amount: nil)
68
+ sp, domain, message = parse(signing_payload)
69
+ pay = payment_fields(payment)
70
+ refuse("cannot check the authorization without the address of the signing key") if blank?(signer)
71
+
72
+ check_typed_data(sp[:primaryType], sp[:types])
73
+ check_from(message[:from], signer.to_s)
74
+ check_chain(domain[:chainId], pay[:chain_id])
75
+ check_address(domain[:verifyingContract], pay[:token], "token") do |got, want|
76
+ "the authorization is against token #{got} but this payment's token is #{want}"
77
+ end
78
+ check_address(message[:to], pay[:rail0_contract], "rail0_contract") do |got, want|
79
+ "the authorization pays #{got} but this payment's RAIL0 contract is #{want}"
80
+ end
81
+ check_value(message[:value], expected_base_units(pay, amount))
82
+ sp
83
+ end
84
+
85
+ def self.parse(signing_payload)
86
+ refuse("there is no signing payload to check") unless signing_payload.is_a?(Hash)
87
+ sp = symbolize(signing_payload)
88
+ domain = sp[:domain]
89
+ message = sp[:message]
90
+ refuse("the signing payload has no domain object") unless domain.is_a?(Hash)
91
+ refuse("the signing payload has no message object") unless message.is_a?(Hash)
92
+ [sp, symbolize(domain), symbolize(message)]
93
+ end
94
+
95
+ def self.payment_fields(payment)
96
+ refuse("cannot check the authorization without the payment it is for") unless payment.is_a?(Hash)
97
+ symbolize(payment)
98
+ end
99
+
100
+ private_class_method :parse, :payment_fields
101
+
102
+ # The EIP-3009 authorization types the gateway emits. Which one a payment needs is the
103
+ # deployment's contract version, which no payment response carries: 1.4.0 and later
104
+ # use ReceiveWithAuthorization for authorize/charge, 1.1.0–1.3.x used
105
+ # TransferWithAuthorization, and refunds are ReceiveWithAuthorization on every
106
+ # version. So both are accepted and nothing else is.
107
+ PRIMARY_TYPES = %w[TransferWithAuthorization ReceiveWithAuthorization].freeze
108
+
109
+ # The EIP-3009 struct shared by both types: names, types AND order are the typehash.
110
+ AUTHORIZATION_FIELDS = [
111
+ %w[from address], %w[to address], %w[value uint256],
112
+ %w[validAfter uint256], %w[validBefore uint256], %w[nonce bytes32]
113
+ ].freeze
114
+
115
+ # The token's EIP-712 domain, as the gateway declares it and the token hashes it.
116
+ DOMAIN_FIELDS = [
117
+ %w[name string], %w[version string], %w[chainId uint256], %w[verifyingContract address]
118
+ ].freeze
119
+
120
+ private_constant :PRIMARY_TYPES, :AUTHORIZATION_FIELDS, :DOMAIN_FIELDS
121
+
122
+ # The typed-data envelope: refuses an unknown +primaryType+, a +types[primaryType]+
123
+ # that differs from the EIP-3009 struct in any name, type or position, an
124
+ # +EIP712Domain+ that differs from the token domain, or any further type.
125
+ def self.check_typed_data(primary_type, types)
126
+ unless PRIMARY_TYPES.include?(primary_type)
127
+ refuse("the authorization's primaryType #{primary_type.inspect} is not an EIP-3009 authorization type " \
128
+ "(TransferWithAuthorization or ReceiveWithAuthorization)")
129
+ end
130
+ refuse("the signing payload has no types") unless types.is_a?(Hash)
131
+
132
+ types = types.transform_keys(&:to_s)
133
+ extra = types.keys.find { |name| name != primary_type && name != "EIP712Domain" }
134
+ if extra
135
+ refuse("the signing payload's types define #{extra.inspect}, which an EIP-3009 authorization does not use")
136
+ end
137
+ check_type_fields("types.#{primary_type}", types[primary_type], AUTHORIZATION_FIELDS)
138
+ check_type_fields("types.EIP712Domain", types["EIP712Domain"], DOMAIN_FIELDS) if types.key?("EIP712Domain")
139
+ end
140
+
141
+ def self.check_type_fields(label, fields, want)
142
+ return if exact_fields?(fields, want)
143
+
144
+ expected = want.map { |name, type| "#{type} #{name}" }.join(",")
145
+ refuse("#{label} is (#{describe_fields(fields)}) but must be exactly (#{expected})")
146
+ end
147
+
148
+ def self.describe_fields(fields)
149
+ return (fields.nil? ? "null" : fields.inspect) unless fields.is_a?(Array)
150
+
151
+ fields.map { |f| f.is_a?(Hash) ? "#{field_of(f, 'type')} #{field_of(f, 'name')}" : f.inspect }.join(",")
152
+ end
153
+
154
+ def self.exact_fields?(fields, want)
155
+ fields.is_a?(Array) && fields.size == want.size &&
156
+ fields.each_with_index.all? do |f, i|
157
+ f.is_a?(Hash) && f.size == 2 && field_of(f, "name") == want[i][0] && field_of(f, "type") == want[i][1]
158
+ end
159
+ end
160
+
161
+ # A field entry's +name+ / +type+, whether the payload arrived string- or
162
+ # symbol-keyed.
163
+ def self.field_of(field, key)
164
+ field.key?(key) ? field[key] : field[key.to_sym]
165
+ end
166
+
167
+ private_class_method :check_typed_data, :check_type_fields, :describe_fields, :exact_fields?, :field_of
168
+
169
+ # Check 1: from = the key's own address. An absent +from+ is a mismatch, not a skip.
170
+ def self.check_from(from, signer)
171
+ return if same_address?(from, signer)
172
+
173
+ refuse("the authorization is from #{from} but the signing key is #{signer}")
174
+ end
175
+
176
+ # Check 2. Both sides may arrive as an Integer or a decimal String.
177
+ def self.check_chain(got, want)
178
+ refuse("cannot check the authorization's chain without the payment's chain_id") if blank?(want)
179
+ return if decimal?(got) && decimal?(want) && Integer(got.to_s, 10) == Integer(want.to_s, 10)
180
+
181
+ refuse("the authorization is scoped to chain #{got} but this payment is on chain #{want}")
182
+ end
183
+
184
+ # Checks 3 and 4: an address against the payment record's, case-insensitively (the
185
+ # gateway may return checksummed addresses). The block words the mismatch.
186
+ def self.check_address(got, want, field)
187
+ refuse("cannot check the authorization without the payment's #{field}") if blank?(want)
188
+ return if same_address?(got, want)
189
+
190
+ refuse(yield(got, want))
191
+ end
192
+
193
+ # Check 5: exact equality in base units. A value that is not a plain decimal integer
194
+ # cannot equal one, so it refuses with the same message rather than being coerced
195
+ # (the signer's +to_i+ would read "1000000abc" as 1000000).
196
+ def self.check_value(got, want)
197
+ return if decimal?(got) && Integer(got.to_s, 10) == Integer(want, 10)
198
+
199
+ refuse("the authorization is for #{got} base units but you asked for #{want}")
200
+ end
201
+
202
+ private_class_method :check_from, :check_chain, :check_address, :check_value
203
+
204
+ # The base-unit amount the authorization must carry. The caller's own +amount:+ is
205
+ # the trustworthy side, so it wins and is scaled locally by the payment's decimals;
206
+ # without one, the payment's stored base-unit amount is what is being agreed to.
207
+ # Either way it never returns nil: the whole guard exists for this comparison.
208
+ def self.expected_base_units(pay, amount)
209
+ if blank?(amount)
210
+ stored = pay[:amount]
211
+ return stored.to_s if decimal?(stored)
212
+
213
+ refuse("cannot check the authorization's amount without an amount: pass amount: " \
214
+ "or a payment record carrying its base-unit amount")
215
+ end
216
+ if pay[:decimals].nil?
217
+ refuse("the payment's token decimals are unknown, so the authorization's amount cannot be checked " \
218
+ "(fetch the payment with payments.get first)")
219
+ end
220
+ TxVerify.to_base_units(amount.to_s, Integer(pay[:decimals]))
221
+ end
222
+
223
+ private_class_method :expected_base_units
224
+
225
+ def self.refuse(message)
226
+ raise TxVerify::RefusedSignature, "refusing to sign: #{message}"
227
+ end
228
+
229
+ def self.symbolize(hash)
230
+ hash.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
231
+ end
232
+
233
+ def self.blank?(value)
234
+ value.nil? || value.to_s.strip.empty?
235
+ end
236
+
237
+ def self.decimal?(value)
238
+ value.is_a?(Integer) ? value >= 0 : value.is_a?(String) && value.match?(/\A\d+\z/)
239
+ end
240
+
241
+ # Both sides must be addresses: two blanks (or two identical non-addresses) are not
242
+ # a match.
243
+ def self.same_address?(left, right)
244
+ TxVerify.address?(left) && TxVerify.address?(right) && left.casecmp(right).zero?
245
+ end
246
+
247
+ private_class_method :refuse, :symbolize, :blank?, :decimal?, :same_address?
248
+ end
249
+ end
@@ -132,9 +132,9 @@ module Rail0
132
132
  #
133
133
  # @param private_key [String] 0x-prefixed hex private key of the account wallet.
134
134
  # @param domain [String] Host of the API server (e.g. "api.rail0.xyz").
135
- # @param chain_id [Integer] Chain ID to embed in the SIWE message. Must match
136
- # the gateway's SIWE_CHAIN_ID policy (default 1); override only when the
137
- # gateway is configured with a different login chain.
135
+ # @param chain_id [Integer] Chain ID to embed in the SIWE message (default 1).
136
+ # The gateway accepts 1 or the id of any chain it has active (GET /blockchains);
137
+ # anything else is refused with 422 siwe_chain_mismatch.
138
138
  # @return [Hash] { token:, address:, account_id:, name:, expires_at:, admin: }
139
139
  def login(private_key:, domain:, chain_id: 1)
140
140
  message, signature = sign_proof(private_key, domain, chain_id, LOGIN_STATEMENT)
data/lib/rail0/signing.rb CHANGED
@@ -14,6 +14,7 @@ ensure
14
14
  end
15
15
 
16
16
  require_relative "tx_verify"
17
+ require_relative "payload_verify"
17
18
 
18
19
  module Rail0
19
20
  # EIP-712 and EIP-3009 signing utilities for RAIL0 payments.
@@ -27,7 +28,7 @@ module Rail0
27
28
  # ## Typical usage (simplest path)
28
29
  #
29
30
  # resp = client.payments.create(chain_id: 84532, mode: "authorize", amount: "100.00", token: "0x...", payer: "0x...", payee: "0x...")
30
- # sig = Rail0::Signing.sign_payload(BUYER_PRIVATE_KEY, resp[:signing_payload])
31
+ # sig = Rail0::Signing.sign_signing_payload(resp[:signing_payload], BUYER_PRIVATE_KEY, payment: resp)
31
32
  # client.payments.sign(resp[:rail0_id], { signature: sig.to_hex })
32
33
  #
33
34
  module Signing
@@ -63,7 +64,7 @@ module Rail0
63
64
  # DEPRECATED — the parameter object of the removed {sign_authorize} /
64
65
  # {sign_charge}. Kept only so existing code reaches those methods' migration
65
66
  # message instead of a NameError while building their arguments; nothing in this
66
- # SDK consumes it. Use {sign_payload} with the gateway's `signing_payload`.
67
+ # SDK consumes it. Use {sign_signing_payload} with the gateway's `signing_payload`.
67
68
  SignPaymentParams = Struct.new(
68
69
  :private_key, :payment, :nonce, :contract_address, :token_domain,
69
70
  keyword_init: true
@@ -279,13 +280,22 @@ module Rail0
279
280
  f = unsigned_transaction.is_a?(String) ? JSON.parse(unsigned_transaction) : unsigned_transaction
280
281
  f = f.transform_keys(&:to_s)
281
282
 
283
+ # Even on the deprecated blind path: Eth::Tx treats a nil or "" `to` as a
284
+ # contract creation and signs it, which would have this key deploy whatever init
285
+ # code the gateway put in `data`. No RAIL0 operation is ever a creation.
286
+ unless TxVerify.address?(f["to"].to_s.strip)
287
+ raise TxVerify::RefusedSignature,
288
+ "refusing to sign: the transaction's `to` #{f['to'].inspect} is not an address " \
289
+ "(an empty `to` would deploy a contract)"
290
+ end
291
+
282
292
  tx = Eth::Tx.new(
283
293
  chain_id: Integer(f.fetch("chain_id")),
284
294
  nonce: Integer(f.fetch("nonce")),
285
295
  priority_fee: Integer(f.fetch("max_priority_fee_per_gas")),
286
296
  max_gas_fee: Integer(f.fetch("max_fee_per_gas")),
287
297
  gas_limit: Integer(f.fetch("gas_limit")),
288
- to: f.fetch("to"),
298
+ to: "0x#{f['to'].to_s.strip[2..]}",
289
299
  value: Integer(f["value"] || 0),
290
300
  data: f["data"].to_s
291
301
  )
@@ -299,24 +309,79 @@ module Rail0
299
309
  "0x#{tx.hex}"
300
310
  end
301
311
 
312
+ # Verify, then sign, the EIP-3009 +signing_payload+ of a create, get or refund
313
+ # prepare response. The checked form — prefer it over the bare {sign_payload}.
314
+ #
315
+ # Before the key touches the payload, {Rail0::PayloadVerify.verify} holds it to
316
+ # +payment:+: +message.from+ is this key's address, +domain.chainId+ the payment's
317
+ # chain, +domain.verifyingContract+ its token, +message.to+ its RAIL0 contract, and
318
+ # +message.value+ exactly the amount — +amount:+ scaled by the payment's +decimals+
319
+ # when given (a refund), else the payment's stored base-unit +amount+ (the payer of a
320
+ # created payment). A mismatch raises {Rail0::TxVerify::RefusedSignature} naming the
321
+ # field, and nothing is signed. Everything compared is already in hand: no extra
322
+ # gateway call. The payload is then signed verbatim, as {sign_payload} does.
323
+ #
324
+ # payment = client.payments.create(chain_id: 84532, mode: "authorize", amount: "100.00", ...)
325
+ # sig = Rail0::Signing.sign_signing_payload(payment[:signing_payload], BUYER_PRIVATE_KEY,
326
+ # payment: payment)
327
+ # client.payments.sign(payment[:rail0_id], { signature: sig.to_hex })
328
+ #
329
+ # payment = client.payments.get(rail0_id)
330
+ # p1 = client.payments.refund_prepare(rail0_id, amount: "50.00")
331
+ # sig = Rail0::Signing.sign_signing_payload(p1[:signing_payload], MERCHANT_PRIVATE_KEY,
332
+ # payment: payment, amount: "50.00")
333
+ #
334
+ # The key is a private-key String, an Eth::Key, or any object responding to
335
+ # +#sign(digest)+ AND +#address+ — the address is what +message.from+ is checked
336
+ # against, so a remote signer (KMS, HSM) must expose the address it signs for.
337
+ #
338
+ # @param signing_payload [Hash] the response's +signing_payload+
339
+ # @param private_key [String, Eth::Key, #sign] the signer
340
+ # @param payment [Hash] the payment record the authorization is for
341
+ # @param amount [String, nil] the human decimal asked for (refund); nil = the payment's amount
342
+ # @return [Eip3009Signature]
343
+ def self.sign_signing_payload(signing_payload, private_key, payment:, amount: nil)
344
+ signer = signer_for(private_key)
345
+ unless signer.respond_to?(:address)
346
+ raise ArgumentError,
347
+ "the checked signer needs the key's address to compare with the authorization's `from`: " \
348
+ "pass a key String or Eth::Key, or a signer responding to #address"
349
+ end
350
+
351
+ PayloadVerify.verify(signing_payload, payment: payment, signer: signer.address.to_s, amount: amount)
352
+ sign_verbatim(signer, signing_payload)
353
+ end
354
+
302
355
  # Sign the EIP-3009 payload using the signing_payload returned by POST /payments.
303
356
  #
304
- # This is the simplest entry point: pass the full signing_payload from the create response
305
- # and a private key — all fields are read directly from the payload without any manual
306
- # reconstruction. (The payload's inner domain/message keep EIP-712 camelCase — chainId,
357
+ # All fields are read directly from the payload without any manual reconstruction.
358
+ # (The payload's inner domain/message keep EIP-712 camelCase — chainId,
307
359
  # verifyingContract, validAfter, validBefore — which this reads directly.)
308
360
  #
309
- # resp = client.payments.create(
310
- # chain_id: 84532, mode: "authorize",
311
- # amount: "100.00", token: "0x...", payer: "0x...", payee: "0x..."
312
- # )
313
- # sig = Rail0::Signing.sign_payload(BUYER_PRIVATE_KEY, resp[:signing_payload])
314
- # client.payments.sign(resp[:rail0_id], { signature: sig.to_hex })
361
+ # With +payment:+ it is {sign_signing_payload}: the payload is VERIFIED against the
362
+ # payment first. Without it the payload is signed BLINDLY — a compromised or
363
+ # misconfigured endpoint could have this key sign an authorization the token honours
364
+ # on its own, for any amount up to the key's balance. That form is DEPRECATED (a
365
+ # +:deprecated+ warning is emitted; enable them with +Warning[:deprecated] = true+ or
366
+ # +-W:deprecated+) and will be removed in the next major version.
315
367
  #
316
- # @param private_key [String] Payer's private key (0x-prefixed hex).
368
+ # @param private_key [String, Eth::Key, #sign] Signer (a private key, 0x-prefixed hex).
317
369
  # @param signing_payload [Hash] The signingPayload hash from the create response.
370
+ # @param payment [Hash, nil] The payment record the authorization is for.
371
+ # @param amount [String, nil] The human decimal asked for (refund).
318
372
  # @return [Eip3009Signature]
319
- def self.sign_payload(private_key, signing_payload)
373
+ def self.sign_payload(private_key, signing_payload, payment: nil, amount: nil)
374
+ return sign_signing_payload(signing_payload, private_key, payment: payment, amount: amount) if payment || amount
375
+
376
+ warn("Rail0::Signing.sign_payload without payment: signs the gateway's authorization blindly " \
377
+ "and is deprecated; use sign_signing_payload(signing_payload, key, payment:) so it is " \
378
+ "verified first", uplevel: 1, category: :deprecated)
379
+ sign_verbatim(private_key, signing_payload)
380
+ end
381
+
382
+ # Signs a payload exactly as the gateway built it: domain, typehash (from its own
383
+ # primaryType, never guessed) and message fields all come from the payload.
384
+ def self.sign_verbatim(private_key, signing_payload)
320
385
  payload = symbolize(signing_payload)
321
386
  d = symbolize(payload[:domain])
322
387
  m = symbolize(payload[:message])
@@ -342,9 +407,11 @@ module Rail0
342
407
  )
343
408
  end
344
409
 
410
+ private_class_method :sign_verbatim
411
+
345
412
  # Sign a raw EIP-3009 transferWithAuthorization message.
346
413
  #
347
- # For RAIL0 payment flows prefer {sign_payload} which reads all fields from the
414
+ # For RAIL0 payment flows prefer {sign_signing_payload} which reads all fields from the
348
415
  # API-returned signingPayload. Use this method only when you need full control over
349
416
  # the message fields (e.g. integrating with a contract directly).
350
417
  #
@@ -368,7 +435,7 @@ module Rail0
368
435
  )
369
436
  end
370
437
 
371
- # REMOVED — see {sign_payload}.
438
+ # REMOVED — see {sign_signing_payload}.
372
439
  #
373
440
  # These two rebuilt the EIP-3009 digest from a payment record: they read
374
441
  # from/value/validBefore off the payment, hardcoded `validAfter = 0` and `to =
@@ -389,7 +456,8 @@ module Rail0
389
456
  "Rail0::Signing.%s was removed: it rebuilt the EIP-3009 digest from a payment " \
390
457
  "record, which signs the WRONG digest as soon as the contract's payload changes " \
391
458
  "(rail0#58 moves authorize/charge to ReceiveWithAuthorization). Use " \
392
- "Rail0::Signing.sign_payload(private_key, create_response[:signing_payload]) — " \
459
+ "Rail0::Signing.sign_signing_payload(create_response[:signing_payload], private_key, " \
460
+ "payment: create_response) — " \
393
461
  "the gateway builds the payload, clients sign it verbatim."
394
462
  private_constant :REMOVED_MESSAGE
395
463
 
@@ -80,16 +80,17 @@ module Rail0
80
80
  # transfers the contract performs, so a non-zero value is the gateway asking for ETH;
81
81
  # 3. +chain_id+ is the payment's chain;
82
82
  # 4. +from+, when present, is +signer+ (the signing key's address);
83
- # 5. +to+ is the payment's +rail0_contract+ — a consistency check (the address comes
84
- # from the same gateway), but it catches a response stitched from two payments;
83
+ # 5. +to+ is an address and is the payment's +rail0_contract+ — a consistency check
84
+ # (the address comes from the same gateway), but it catches a response stitched
85
+ # from two payments, and an empty +to+ (a contract creation) always refuses;
85
86
  # 6. the 4-byte selector is the RAIL0 function for +operation+, and for capture and
86
87
  # refund the amount word equals +amount+ scaled by the payment's +decimals+;
87
88
  # 7. the paymentId word is the payment's +rail0_id+.
88
89
  #
89
90
  # Every right-hand side is something the caller supplied or derived locally — never
90
91
  # the prepare response itself. FAIL-CLOSED on what cannot be compared: an unknown
91
- # operation, a capture/refund without an +amount+, or a payment with unknown
92
- # +decimals+ refuses rather than skips. Only the paymentId comparison is skipped,
92
+ # operation, a capture/refund without an +amount+, a payment with no
93
+ # +rail0_contract+ or with unknown +decimals+ refuses rather than skips. Only the paymentId comparison is skipped,
93
94
  # when the payment carries no usable +rail0_id+.
94
95
  #
95
96
  # @param unsigned_transaction [String, Hash] the prepare response's field-set
@@ -126,10 +127,34 @@ module Rail0
126
127
  refuse("transaction is prepared from #{from} but the signing key is #{signer}")
127
128
  end
128
129
 
129
- to = fields["to"].to_s
130
- return unless pay[:rail0_contract] && !to.empty? && !same_address?(to, pay[:rail0_contract])
130
+ check_to(fields["to"], pay[:rail0_contract])
131
+ end
132
+
133
+ # Check 5. +to+ used to be compared only when it was present: an absent, nil or ""
134
+ # +to+ skipped the check, and Eth::Tx signs a transaction with no recipient as a
135
+ # CONTRACT CREATION — the calldata becomes init code run by the signing key, which
136
+ # deploys whatever the gateway sent from the merchant's own account. So an empty or
137
+ # malformed +to+ refuses outright, and so does a payment record with no
138
+ # +rail0_contract+ to compare it with: every RAIL0 operation is a call to that one
139
+ # contract, and nothing else here can tell a legitimate recipient from a hostile one.
140
+ def self.check_to(to_field, rail0_contract)
141
+ to = to_field.to_s.strip
142
+ refuse("the transaction has no `to` address; signing it would deploy a contract") if to.empty?
143
+ refuse("the transaction's `to` #{to.inspect} is not an address") unless address?(to)
144
+ if rail0_contract.nil?
145
+ refuse("cannot check the transaction's `to` without the payment's rail0_contract " \
146
+ "(fetch the payment with payments.get first)")
147
+ end
148
+ return if same_address?(to, rail0_contract)
131
149
 
132
- refuse("transaction is addressed to #{to} but this payment's RAIL0 contract is #{pay[:rail0_contract]}")
150
+ refuse("transaction is addressed to #{to} but this payment's RAIL0 contract is #{rail0_contract}")
151
+ end
152
+
153
+ # A 20-byte, 0x-prefixed hex address, any case (prefix included). Shared with
154
+ # {Rail0::PayloadVerify} and the signer, which applies it even to the deprecated
155
+ # blind form.
156
+ def self.address?(value)
157
+ value.is_a?(String) && value.match?(/\A0x\h{40}\z/i)
133
158
  end
134
159
 
135
160
  def self.parse(unsigned_transaction)
@@ -152,7 +177,7 @@ module Rail0
152
177
  s.empty? ? nil : s
153
178
  end
154
179
 
155
- private_class_method :check_header, :parse, :payment_fields, :presence
180
+ private_class_method :check_header, :check_to, :parse, :payment_fields, :presence
156
181
 
157
182
  def self.refuse(message)
158
183
  raise RefusedSignature, "refusing to sign: #{message}"
@@ -197,6 +222,12 @@ module Rail0
197
222
  # String math, no Float: "1.5" at 6 decimals is "1500000". More fractional digits
198
223
  # than the token has is a refusal, not a truncation — the gateway 422s such an
199
224
  # amount, so dropping digits would compare against a figure never asked for.
225
+ # Public because {Rail0::PayloadVerify} scales the caller's amount the same way.
226
+ #
227
+ # @param human [String] a decimal amount as the caller typed it ("1.5")
228
+ # @param decimals [Integer] the token's decimals
229
+ # @return [String] the amount in base units
230
+ # @raise [RefusedSignature] when it is not a plain decimal, or is finer than +decimals+
200
231
  def self.to_base_units(human, decimals)
201
232
  s = human.strip
202
233
  refuse("amount #{human.inspect} is not a decimal number") unless s.match?(/\A\d+(\.\d+)?\z/)
@@ -208,7 +239,7 @@ module Rail0
208
239
  (whole + frac.ljust(decimals, "0")).sub(/\A0+(?=\d)/, "")
209
240
  end
210
241
 
211
- private_class_method :expected_base_units, :to_base_units
242
+ private_class_method :expected_base_units
212
243
 
213
244
  # Asserts the call is the RAIL0 function for +operation+ and, for capture and refund,
214
245
  # that it moves exactly +want_units+ base units.
data/lib/rail0/types.rb CHANGED
@@ -457,17 +457,17 @@ module Rail0
457
457
  keyword_init: true
458
458
  )
459
459
 
460
- # One reported policy value.
461
- PolicySetting = Struct.new(
460
+ # One reported configuration value.
461
+ ConfigSetting = Struct.new(
462
462
  :value, # The effective value (number, boolean, string, list or map).
463
463
  :env, # String — The environment variable that sets it; null for a constant.
464
464
  :overridden, # Boolean — Whether that variable is set in this process.
465
- :description, # String — Plain-text explanation of the entry: what it controls, which part of the gateway reads it and the effect of raising or lowering it, and its unit (from config/policy_descriptions.yml). Every entry has one; older gateways omit the field.
465
+ :description, # String — Plain-text explanation of the entry: what it controls, which part of the gateway reads it and the effect of raising or lowering it, and its unit (from config/config_descriptions.yml). Every entry has one; older gateways omit the field.
466
466
  keyword_init: true
467
467
  )
468
468
 
469
- # GET /admin/policy: the effective policy, grouped. Every leaf is a PolicySetting.
470
- PolicyReport = Struct.new(
469
+ # GET /admin/config: the effective configuration, grouped. Every leaf is a ConfigSetting.
470
+ ConfigReport = Struct.new(
471
471
  :payments, # Hash
472
472
  :recovery, # Hash
473
473
  :retention, # Hash
data/lib/rail0/version.rb CHANGED
@@ -5,5 +5,5 @@ module Rail0
5
5
  # consumed by path from a sibling checkout, where the number bought nothing: no registry
6
6
  # ever served a 1.x, no tag was ever cut, and the one consumer resolves the working tree.
7
7
  # Publishing from 1.2.0 would have implied two releases nobody can install.
8
- VERSION = "1.6.0"
8
+ VERSION = "1.7.1"
9
9
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rail0-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0
4
+ version: 1.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Commerce Layer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-19 00:00:00.000000000 Z
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger
@@ -44,6 +44,7 @@ files:
44
44
  - lib/rail0/dispute_reasons.rb
45
45
  - lib/rail0/error_hints.rb
46
46
  - lib/rail0/http_client.rb
47
+ - lib/rail0/payload_verify.rb
47
48
  - lib/rail0/request.rb
48
49
  - lib/rail0/resources/accounts.rb
49
50
  - lib/rail0/resources/analytics.rb