rail0-sdk 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e046261654c0f0d93915dd0ac7003e78f224115748e697581cac6020c4ea4003
4
- data.tar.gz: f9fea94c35a6b996732e0bbdc538361a229134edaa4f52bc6a010e1dfd55b871
3
+ metadata.gz: 861d8ebab53accbd5a962ed1cb040a91461841b63722077b93374291f8de66bd
4
+ data.tar.gz: e7d22a5a5d2e9ad6a22149427195b1d7d341e15da84e1d858f453d343859b771
5
5
  SHA512:
6
- metadata.gz: dcb35d580188aef4a5e6b36878e6e8b3da40dddd671a792eb3275f4baad286636ec6f00e6284d1a0263c6ec764d539e17d8c85ea1904b77f3f1326e8929174fd
7
- data.tar.gz: 592f3db30a8c9ecdc755f5b64e9dc0a69166ca73f6b51efcae2c348fb28e60d39b148b41b3cd54015012837c7a8987583d3bc6621f6ef82a4bca3e8b14381526
6
+ metadata.gz: b926413fd2b987d43b60b78216506baa56eb8954c3d51c7ba2e61a977e597a250c2b961772a27fba93bca1a2f08b036250c15111ecac692823e3ab84548f2004
7
+ data.tar.gz: a43b612c63ea9114034c4e80eeccb9670758bfecae1267efe5568f322475adfc8dcdf2f935b35f40d9cfcf8559b94b1e65a00b36093c937bf5ac6a385920ba5c
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,46 @@ 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, against data you already hold:
154
+ `message.from` is your key's address, `domain.chainId` the payment's `chain_id`,
155
+ `domain.verifyingContract` the payment's `token`, `message.to` its `rail0_contract`, and
156
+ `message.value` **exactly** the amount — `amount:` scaled by the payment's `decimals` when
157
+ you pass one (a refund), otherwise the payment's own base-unit `amount` (a created
158
+ payment). The first mismatch raises `Rail0::TxVerify::RefusedSignature` ("refusing to
159
+ sign: …", naming the field) and nothing is signed. It fails closed: a payment record
160
+ missing `chain_id`, `token`, `rail0_contract` or an amount to compare is refused, and so
161
+ are unknown `decimals` and an amount finer than the token's. The payload is still signed
162
+ verbatim (typehash from its own `primaryType`). Not checked, as in the CLI: the validity
163
+ window and `message.nonce` (recomputing it needs the deployment's `VERSION()`, which no
164
+ payment response carries), and `to` and the decimals come from the same gateway. The
165
+ residual is an authorization for the right token, chain and signer, for exactly the
166
+ amount you asked. `sign_payload(key, signing_payload)` without `payment:` still signs
167
+ blindly and is **deprecated** (`:deprecated`-category warning); with `payment:` it is
168
+ `sign_signing_payload`. `Rail0::PayloadVerify.verify(signing_payload, payment:, signer:,
169
+ amount: nil)` runs the check on its own.
170
+
144
171
  Wallets that sign **and** broadcast in one step (e.g. MetaMask via
145
172
  `eth_sendTransaction`) skip steps 2–3 and report the hash instead:
146
173
 
@@ -211,8 +238,8 @@ auth = client.auth.login(private_key: "0x…", domain: "api.rail0.xyz")
211
238
  # admin is always a boolean (verify too): true only for an account holding the
212
239
  # operator grant, false otherwise (visibility only). account_id/name are nil for an
213
240
  # 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)).
241
+ # login embeds chain_id 1 by default; the gateway also accepts the id of any chain it
242
+ # has active (GET /blockchains), e.g. login(private_key:, domain:, chain_id: 5042002).
216
243
 
217
244
  authed = Rail0::Client.new(
218
245
  base_url: "https://api.rail0.xyz",
@@ -488,8 +515,11 @@ transactions, so a bulk action can skip busy rows without a `get(id)` each.
488
515
 
489
516
  ```ruby
490
517
  # Phase 1 — amount only → returns a signing payload for the payee to sign.
518
+ # The authorization is checked against the payment and the refund amount
519
+ # (payment = client.payments.get(rail0_id)) before the key signs it.
491
520
  p1 = client.payments.refund_prepare(rail0_id, amount: "20.00")
492
- sig = Rail0::Signing.sign_payload(MERCHANT_PRIVATE_KEY, p1[:signing_payload])
521
+ sig = Rail0::Signing.sign_signing_payload(p1[:signing_payload], MERCHANT_PRIVATE_KEY,
522
+ payment: payment, amount: "20.00")
493
523
 
494
524
  # Phase 2 — amount + signature → returns the unsigned on-chain tx.
495
525
  p2 = client.payments.refund_prepare(rail0_id, amount: "20.00", signature: sig.to_hex)
@@ -795,18 +825,25 @@ Requires the `eth` gem. No private key ever leaves your process.
795
825
 
796
826
  | Method | Use |
797
827
  |--------|-----|
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 |
828
+ | `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 |
829
+ | `sign_payload(signer, signing_payload, payment: nil, amount: nil)` | The same signer; with `payment:` it is `sign_signing_payload`, without it signs blindly (**deprecated**) |
830
+ | `Rail0::PayloadVerify.verify(signing_payload, payment:, signer:, amount: nil)` | The payload pre-sign check on its own |
799
831
  | `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
832
  | `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
833
  | `sign_transfer_with_authorization(signer, domain, params)` | Raw EIP-3009 `TransferWithAuthorization` signer, for talking to a token directly |
802
834
 
803
835
  ```ruby
804
836
  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 })
837
+ payment = client.payments.create(chain_id: 84532, mode: "authorize", amount: "50.00", token: TOKEN,
838
+ payer: BUYER, payee: MERCHANT)
839
+ sig = Rail0::Signing.sign_signing_payload(payment[:signing_payload], BUYER_PRIVATE_KEY, payment: payment)
840
+ client.payments.sign(payment[:rail0_id], { signature: sig.to_hex })
841
+ # A hostile payload — another `to`, a larger `value`, another token — raises instead:
842
+ # Rail0::TxVerify::RefusedSignature: refusing to sign: the authorization is for
843
+ # 999999999999 base units but you asked for 50000000
807
844
  ```
808
845
 
809
- **The gateway builds the payload; you sign it verbatim.** `sign_payload` takes the
846
+ **The gateway builds the payload; you sign it verbatim.** `sign_signing_payload` takes the
810
847
  typehash from the payload's `primaryType` and every field from its `message` — only
811
848
  the gateway knows which contract version a payment lives on, and the version selects
812
849
  the typehash, the EIP-712 domain and the field layout. An unrecognised `primaryType`
@@ -818,20 +855,24 @@ digest from a payment record. They raise with a pointer here.
818
855
 
819
856
  ### Signing without a raw key
820
857
 
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
858
+ `sign_signing_payload`, `sign_payload` and `sign_transfer_with_authorization` accept a
859
+ private-key String, an `Eth::Key`, or **any object responding to `#sign(digest)`** that
860
+ returns a 65-byte hex signature — a KMS or HSM client, a remote signer, a hardware-wallet
824
861
  bridge. The SDK builds the EIP-712 digest and hands only that over, so the secret
825
862
  need never be materialised as a String in your process:
826
863
 
827
864
  ```ruby
828
865
  class KmsSigner
829
866
  def sign(digest) = MyKms.sign(key_id: KEY_ID, digest: digest) # -> "0x…" (65 bytes)
867
+ def address = KMS_KEY_ADDRESS # what message.from is checked against
830
868
  end
831
869
 
832
- sig = Rail0::Signing.sign_payload(KmsSigner.new, payment[:signing_payload])
870
+ sig = Rail0::Signing.sign_signing_payload(payment[:signing_payload], KmsSigner.new, payment: payment)
833
871
  ```
834
872
 
873
+ The checked form needs the signer's address to compare with the authorization's `from`,
874
+ so a custom signer must also respond to `#address` (an `ArgumentError` says so otherwise).
875
+
835
876
  `sign_transaction` is narrower on purpose: `Eth::Tx#sign` derives an EIP-155 `v`
836
877
  from the chain id, so it needs a full `Eth::Key` (a String or an `Eth::Key`), not a
837
878
  bare digest signer.
@@ -977,6 +1018,8 @@ lib/rail0/
977
1018
  backoff.rb Rail0::Backoff — retry delays and 429 jitter
978
1019
  webhook_signature.rb Rail0::WebhookSignature — delivery verification
979
1020
  signing.rb EIP-3009 + EIP-1559 signing (requires 'eth')
1021
+ tx_verify.rb Rail0::TxVerify — pre-sign check of an unsigned transaction
1022
+ payload_verify.rb Rail0::PayloadVerify — pre-sign check of an EIP-3009 signing payload
980
1023
  stablecoins.rb stablecoin address registry
981
1024
  dispute_reasons.rb generated Rail0::DisputeReasons — the dispute-reason dictionary
982
1025
  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,174 @@
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
+ # The payload is still signed VERBATIM: the typehash comes from its own +primaryType+
31
+ # and the digest from its own message. This only refuses a message whose fields
32
+ # contradict what the caller asked for. Like the CLI, it does not check +primaryType+
33
+ # against the contract version (the signer already refuses an unknown one), the
34
+ # validity window, or +message.nonce+ — recomputing the nonce needs the deployment's
35
+ # VERSION(), which no payment response carries. The bounded residual is an
36
+ # authorization for the RIGHT token, on the RIGHT chain, from the RIGHT signer, for
37
+ # EXACTLY the amount asked for.
38
+ #
39
+ # FAIL-CLOSED where the CLI is: the amount check never skips (no amount and no stored
40
+ # base-unit amount, or unknown decimals, refuses). The CLI skips a check whose
41
+ # expectation it could not resolve; here every expectation comes from the payment
42
+ # record the caller passes, which always carries chain_id, token and rail0_contract,
43
+ # so a record missing one of them refuses rather than skipping.
44
+ #
45
+ # Loaded by +rail0/signing+ (TxVerify needs the +eth+ gem), never by +require "rail0"+.
46
+ module PayloadVerify
47
+ # Verifies +signing_payload+ against +payment+, BEFORE any key touches it. Raises
48
+ # {Rail0::TxVerify::RefusedSignature} ("refusing to sign: …", naming the field) on
49
+ # the first mismatch; returns the payload, symbol-keyed, otherwise.
50
+ #
51
+ # @param signing_payload [Hash] the +signing_payload+ of a create, get or refund
52
+ # prepare response (string or symbol keys)
53
+ # @param payment [Hash] the payment record: +chain_id+, +token+, +rail0_contract+,
54
+ # and +amount+ (base units) or +decimals+ (to scale +amount:+)
55
+ # @param signer [String] the address of the key about to sign
56
+ # @param amount [String, nil] the human decimal you asked for (a refund's amount).
57
+ # Scaled by the payment's +decimals+. When nil, the payment's own stored +amount+
58
+ # (base units) is the expectation — what the payer of a created payment agrees to.
59
+ # @return [Hash] the payload, symbol-keyed
60
+ def self.verify(signing_payload, payment:, signer:, amount: nil)
61
+ sp, domain, message = parse(signing_payload)
62
+ pay = payment_fields(payment)
63
+ refuse("cannot check the authorization without the address of the signing key") if blank?(signer)
64
+
65
+ check_from(message[:from], signer.to_s)
66
+ check_chain(domain[:chainId], pay[:chain_id])
67
+ check_address(domain[:verifyingContract], pay[:token], "token") do |got, want|
68
+ "the authorization is against token #{got} but this payment's token is #{want}"
69
+ end
70
+ check_address(message[:to], pay[:rail0_contract], "rail0_contract") do |got, want|
71
+ "the authorization pays #{got} but this payment's RAIL0 contract is #{want}"
72
+ end
73
+ check_value(message[:value], expected_base_units(pay, amount))
74
+ sp
75
+ end
76
+
77
+ def self.parse(signing_payload)
78
+ refuse("there is no signing payload to check") unless signing_payload.is_a?(Hash)
79
+ sp = symbolize(signing_payload)
80
+ domain = sp[:domain]
81
+ message = sp[:message]
82
+ refuse("the signing payload has no domain object") unless domain.is_a?(Hash)
83
+ refuse("the signing payload has no message object") unless message.is_a?(Hash)
84
+ [sp, symbolize(domain), symbolize(message)]
85
+ end
86
+
87
+ def self.payment_fields(payment)
88
+ refuse("cannot check the authorization without the payment it is for") unless payment.is_a?(Hash)
89
+ symbolize(payment)
90
+ end
91
+
92
+ private_class_method :parse, :payment_fields
93
+
94
+ # Check 1: from = the key's own address. An absent +from+ is a mismatch, not a skip.
95
+ def self.check_from(from, signer)
96
+ return if same_address?(from, signer)
97
+
98
+ refuse("the authorization is from #{from} but the signing key is #{signer}")
99
+ end
100
+
101
+ # Check 2. Both sides may arrive as an Integer or a decimal String.
102
+ def self.check_chain(got, want)
103
+ refuse("cannot check the authorization's chain without the payment's chain_id") if blank?(want)
104
+ return if decimal?(got) && decimal?(want) && Integer(got.to_s, 10) == Integer(want.to_s, 10)
105
+
106
+ refuse("the authorization is scoped to chain #{got} but this payment is on chain #{want}")
107
+ end
108
+
109
+ # Checks 3 and 4: an address against the payment record's, case-insensitively (the
110
+ # gateway may return checksummed addresses). The block words the mismatch.
111
+ def self.check_address(got, want, field)
112
+ refuse("cannot check the authorization without the payment's #{field}") if blank?(want)
113
+ return if same_address?(got, want)
114
+
115
+ refuse(yield(got, want))
116
+ end
117
+
118
+ # Check 5: exact equality in base units. A value that is not a plain decimal integer
119
+ # cannot equal one, so it refuses with the same message rather than being coerced
120
+ # (the signer's +to_i+ would read "1000000abc" as 1000000).
121
+ def self.check_value(got, want)
122
+ return if decimal?(got) && Integer(got.to_s, 10) == Integer(want, 10)
123
+
124
+ refuse("the authorization is for #{got} base units but you asked for #{want}")
125
+ end
126
+
127
+ private_class_method :check_from, :check_chain, :check_address, :check_value
128
+
129
+ # The base-unit amount the authorization must carry. The caller's own +amount:+ is
130
+ # the trustworthy side, so it wins and is scaled locally by the payment's decimals;
131
+ # without one, the payment's stored base-unit amount is what is being agreed to.
132
+ # Either way it never returns nil: the whole guard exists for this comparison.
133
+ def self.expected_base_units(pay, amount)
134
+ if blank?(amount)
135
+ stored = pay[:amount]
136
+ return stored.to_s if decimal?(stored)
137
+
138
+ refuse("cannot check the authorization's amount without an amount: pass amount: " \
139
+ "or a payment record carrying its base-unit amount")
140
+ end
141
+ if pay[:decimals].nil?
142
+ refuse("the payment's token decimals are unknown, so the authorization's amount cannot be checked " \
143
+ "(fetch the payment with payments.get first)")
144
+ end
145
+ TxVerify.to_base_units(amount.to_s, Integer(pay[:decimals]))
146
+ end
147
+
148
+ private_class_method :expected_base_units
149
+
150
+ def self.refuse(message)
151
+ raise TxVerify::RefusedSignature, "refusing to sign: #{message}"
152
+ end
153
+
154
+ def self.symbolize(hash)
155
+ hash.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
156
+ end
157
+
158
+ def self.blank?(value)
159
+ value.nil? || value.to_s.strip.empty?
160
+ end
161
+
162
+ def self.decimal?(value)
163
+ value.is_a?(Integer) ? value >= 0 : value.is_a?(String) && value.match?(/\A\d+\z/)
164
+ end
165
+
166
+ # Both sides must be addresses: two blanks (or two identical non-addresses) are not
167
+ # a match.
168
+ def self.same_address?(left, right)
169
+ TxVerify.address?(left) && TxVerify.address?(right) && left.casecmp(right).zero?
170
+ end
171
+
172
+ private_class_method :refuse, :symbolize, :blank?, :decimal?, :same_address?
173
+ end
174
+ 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/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.0"
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Commerce Layer
@@ -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