rail0-sdk 1.5.1 → 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: 106a0758b1da0b740e9c6e78fb83ffa9cdbfb53d562c2784b67d544aba2d58c3
4
- data.tar.gz: '09690a456b1bb50bd573944a3ef9b7f980748bb32eb76ce7c17312a70914cf7a'
3
+ metadata.gz: 861d8ebab53accbd5a962ed1cb040a91461841b63722077b93374291f8de66bd
4
+ data.tar.gz: e7d22a5a5d2e9ad6a22149427195b1d7d341e15da84e1d858f453d343859b771
5
5
  SHA512:
6
- metadata.gz: b0e50c24f6276cb4ba23c812dcc8bc7f3b58cf92715855518bb0a76725c3393959487eb29e59794312bd0ccaab49148b436d5c1873cdeb5029db32f5f5a15ef1
7
- data.tar.gz: 59463fad22c007aea05412ade646a21c051db20a85f886bc62897442c810f3c1d3d0f295f03c252c691661be958e8d3f729e7aacbdc0af4a7613a5d13e626483
6
+ metadata.gz: b926413fd2b987d43b60b78216506baa56eb8954c3d51c7ba2e61a977e597a250c2b961772a27fba93bca1a2f08b036250c15111ecac692823e3ab84548f2004
7
+ data.tar.gz: a43b612c63ea9114034c4e80eeccb9670758bfecae1267efe5568f322475adfc8dcdf2f935b35f40d9cfcf8559b94b1e65a00b36093c937bf5ac6a385920ba5c
data/README.md CHANGED
@@ -78,12 +78,18 @@ 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).
85
- prep = client.payments.authorize_prepare(rail0_id)
86
- raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], MERCHANT_PRIVATE_KEY)
87
+ # payment:/operation: are what the gateway's transaction is CHECKED against before
88
+ # the key signs it (see "Payment lifecycle"); `payment` is the record read from the gateway.
89
+ payment = client.payments.get(rail0_id)
90
+ prep = client.payments.authorize_prepare(rail0_id)
91
+ raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], MERCHANT_PRIVATE_KEY,
92
+ payment: payment, operation: "authorize")
87
93
  client.payments.authorize(rail0_id, { signed_transaction: raw }) # HTTP 202 (async)
88
94
 
89
95
  # 4. Poll until the authorization confirms.
@@ -95,7 +101,8 @@ end
95
101
 
96
102
  # 5. Payee captures the escrowed funds (partial capture is supported).
97
103
  cap = client.payments.capture_prepare(rail0_id, "50.00")
98
- raw = Rail0::Signing.sign_transaction(cap[:unsigned_transaction], MERCHANT_PRIVATE_KEY)
104
+ raw = Rail0::Signing.sign_transaction(cap[:unsigned_transaction], MERCHANT_PRIVATE_KEY,
105
+ payment: payment, operation: "capture", amount: "50.00")
99
106
  client.payments.capture(rail0_id, { signed_transaction: raw })
100
107
  ```
101
108
 
@@ -108,12 +115,59 @@ Each on-chain operation is a `prepare` → `submit` pair:
108
115
 
109
116
  1. **prepare** — `client.payments.<op>_prepare(...)` returns a transaction whose
110
117
  `unsigned_transaction` is the EIP-1559 field-set to sign.
111
- 2. **sign** — `Rail0::Signing.sign_transaction(unsigned, private_key)` returns the
112
- signed raw tx.
118
+ 2. **sign** — `Rail0::Signing.sign_transaction(unsigned, private_key, payment:, operation:, amount:)`
119
+ verifies the transaction against the payment record, then returns the signed raw tx.
113
120
  3. **submit** — `client.payments.<op>(id, { signed_transaction: raw })` broadcasts
114
121
  it. The gateway acknowledges with HTTP 202 and confirms asynchronously — poll
115
122
  `client.payments.get(id)` until the status settles.
116
123
 
124
+ **What `sign_transaction` verifies, and why.** The gateway builds the EIP-1559
125
+ transaction and your key signs it, so a compromised or misconfigured endpoint could
126
+ otherwise have that key sign anything — an `approve(spender, MAX)` on the token, a
127
+ `capture` answered to a `void` prepare, a capture for more than you asked. Given
128
+ `payment:` (the record from `payments.get`) and `operation:`, `sign_transaction` checks
129
+ the transaction **before** the key touches it, against data you already hold (no extra
130
+ request): `value` is zero (every RAIL0 operation is value-0), `chain_id` is the
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),
134
+ the 4-byte selector is the RAIL0 function for `operation:` (the selectors are derived
135
+ from the contract's function signatures, `Rail0::TxVerify::SELECTORS`, and pinned
136
+ against its ABI in the spec suite), the first calldata word is the payment's `rail0_id`,
137
+ and — for `capture` and `refund` — the amount word equals `amount:` scaled by the
138
+ payment's `decimals`. The first mismatch raises `Rail0::TxVerify::RefusedSignature`
139
+ ("refusing to sign: …", naming the field) and no signature is produced. It fails closed:
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
142
+ gateway — which is why the calldata binding to this payment's id and operation is what
143
+ closes a swapped transaction. Calling `sign_transaction` without `payment:` still signs
144
+ blindly and is **deprecated** (a `:deprecated`-category warning; shown with
145
+ `-W:deprecated`). `Rail0::TxVerify.verify` runs the check on its own, for a signer that
146
+ is not a local key.
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
+
117
171
  Wallets that sign **and** broadcast in one step (e.g. MetaMask via
118
172
  `eth_sendTransaction`) skip steps 2–3 and report the hash instead:
119
173
 
@@ -184,8 +238,8 @@ auth = client.auth.login(private_key: "0x…", domain: "api.rail0.xyz")
184
238
  # admin is always a boolean (verify too): true only for an account holding the
185
239
  # operator grant, false otherwise (visibility only). account_id/name are nil for an
186
240
  # account-less session.
187
- # login embeds chain_id 1 by default; pass chain_id: to match a gateway whose
188
- # 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).
189
243
 
190
244
  authed = Rail0::Client.new(
191
245
  base_url: "https://api.rail0.xyz",
@@ -461,12 +515,16 @@ transactions, so a bulk action can skip busy rows without a `get(id)` each.
461
515
 
462
516
  ```ruby
463
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.
464
520
  p1 = client.payments.refund_prepare(rail0_id, amount: "20.00")
465
- 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")
466
523
 
467
524
  # Phase 2 — amount + signature → returns the unsigned on-chain tx.
468
525
  p2 = client.payments.refund_prepare(rail0_id, amount: "20.00", signature: sig.to_hex)
469
- raw = Rail0::Signing.sign_transaction(p2[:unsigned_transaction], MERCHANT_PRIVATE_KEY)
526
+ raw = Rail0::Signing.sign_transaction(p2[:unsigned_transaction], MERCHANT_PRIVATE_KEY,
527
+ payment: payment, operation: "refund", amount: "20.00")
470
528
  client.payments.refund(rail0_id, { signed_transaction: raw })
471
529
  ```
472
530
 
@@ -479,12 +537,15 @@ pattern. Like the rest of `/payments` they need a session, but the payer's
479
537
  account-less SIWE login is enough:
480
538
 
481
539
  ```ruby
540
+ payment = client.payments.get(rail0_id)
482
541
  prep = client.payments.dispute_prepare(rail0_id, reason: "not_received") # reason optional
483
- raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], BUYER_PRIVATE_KEY)
542
+ raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], BUYER_PRIVATE_KEY,
543
+ payment: payment, operation: "dispute")
484
544
  client.payments.dispute(rail0_id, { signed_transaction: raw })
485
545
  # … later …
486
546
  prep = client.payments.close_dispute_prepare(rail0_id, reason: "withdrawn") # or omit reason:
487
- raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], BUYER_PRIVATE_KEY)
547
+ raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], BUYER_PRIVATE_KEY,
548
+ payment: payment, operation: "close_dispute")
488
549
  client.payments.close_dispute(rail0_id, { signed_transaction: raw })
489
550
  ```
490
551
 
@@ -764,17 +825,25 @@ Requires the `eth` gem. No private key ever leaves your process.
764
825
 
765
826
  | Method | Use |
766
827
  |--------|-----|
767
- | `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 |
768
- | `sign_transaction(unsigned_transaction, key)` | Sign a prepare step's unsigned EIP-1559 transaction; returns the 0x raw tx |
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 |
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**) |
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 |
769
833
  | `sign_transfer_with_authorization(signer, domain, params)` | Raw EIP-3009 `TransferWithAuthorization` signer, for talking to a token directly |
770
834
 
771
835
  ```ruby
772
836
  require "rail0/signing"
773
- sig = Rail0::Signing.sign_payload(BUYER_PRIVATE_KEY, payment[:signing_payload])
774
- 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
775
844
  ```
776
845
 
777
- **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
778
847
  typehash from the payload's `primaryType` and every field from its `message` — only
779
848
  the gateway knows which contract version a payment lives on, and the version selects
780
849
  the typehash, the EIP-712 domain and the field layout. An unrecognised `primaryType`
@@ -786,20 +855,24 @@ digest from a payment record. They raise with a pointer here.
786
855
 
787
856
  ### Signing without a raw key
788
857
 
789
- `sign_payload` and `sign_transfer_with_authorization` accept a private-key String,
790
- an `Eth::Key`, or **any object responding to `#sign(digest)`** that returns a
791
- 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
792
861
  bridge. The SDK builds the EIP-712 digest and hands only that over, so the secret
793
862
  need never be materialised as a String in your process:
794
863
 
795
864
  ```ruby
796
865
  class KmsSigner
797
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
798
868
  end
799
869
 
800
- sig = Rail0::Signing.sign_payload(KmsSigner.new, payment[:signing_payload])
870
+ sig = Rail0::Signing.sign_signing_payload(payment[:signing_payload], KmsSigner.new, payment: payment)
801
871
  ```
802
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
+
803
876
  `sign_transaction` is narrower on purpose: `Eth::Tx#sign` derives an EIP-155 `v`
804
877
  from the chain id, so it needs a full `Eth::Key` (a String or an `Eth::Key`), not a
805
878
  bare digest signer.
@@ -945,6 +1018,8 @@ lib/rail0/
945
1018
  backoff.rb Rail0::Backoff — retry delays and 429 jitter
946
1019
  webhook_signature.rb Rail0::WebhookSignature — delivery verification
947
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
948
1023
  stablecoins.rb stablecoin address registry
949
1024
  dispute_reasons.rb generated Rail0::DisputeReasons — the dispute-reason dictionary
950
1025
  types.rb generated Struct docs of the gateway schema (reference only)
@@ -20,14 +20,15 @@ module Rail0
20
20
  "nothing_to_dispute" => "a dispute needs a merchant-held (refundable) balance",
21
21
  "unknown_dispute_reason" => "reason must be a dispute-reason code (Rail0::DisputeReasons.codes(:open) / codes(:close)) or exactly its bytes32 — full_refund is system-only",
22
22
  "transaction_not_overwritable" => "a transaction for this operation is already in flight — wait for it to settle",
23
- "signer_mismatch" => "the signing key doesn't match the payment's payer/payee",
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,35 @@ 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",
97
+ # Onboarding: POST /signup and POST /claim (the gateway's onboarding flow).
98
+ # The /admin/onboardings codes (onboarding_not_found, onboarding_completed) are left
99
+ # out: those routes are the console's, not the SDK's. The link codes never say which
100
+ # term failed — a stale link has one remedy: the newest email, or a new link.
101
+ "signup_unavailable" => "self-service sign-up is switched off on this gateway for now — try again later, or ask your RAIL0 contact for an invite",
102
+ "onboarding_unavailable" => "this gateway has no onboarding secret configured, so it can neither issue nor verify sign-up links — ask the operator",
103
+ "email_provider_not_configured" => "the gateway has no email provider that delivers, so no link was sent and nothing was saved — ask the operator to configure one",
104
+ "email_domain_not_allowed" => "this environment emails only its allowed domains (the detail lists them) — use an address on one of them; nothing was saved",
105
+ "account_email_taken" => "an account with this email already exists — sign in with its wallet, or use another email",
106
+ "account_wallet_taken" => "this wallet is already registered to an account — sign in with it, or use another wallet",
107
+ "account_name_taken" => "an account with this name already exists — choose another name",
108
+ "terms_required" => "the claim needs terms_version: send the version of the terms the merchant accepted",
109
+ "onboarding_link_invalid" => "the link was altered or its request no longer exists — use the link in the most recent email, or ask for a new one",
110
+ "onboarding_link_expired" => "the link has expired — sign up again, or ask your RAIL0 contact for a new link",
111
+ "onboarding_link_superseded" => "a newer link was sent for this request and this one no longer works — use the link in the most recent email",
82
112
  # A BARE forbidden is not a party mismatch: the gateway split those into codes of
83
113
  # their own (not_the_payee, not_the_payer, wallet_deactivated, account_deactivated,
84
114
  # not_your_account) because they need different fixes. This entry kept describing
@@ -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
@@ -13,6 +13,9 @@ ensure
13
13
  $VERBOSE = original_verbose
14
14
  end
15
15
 
16
+ require_relative "tx_verify"
17
+ require_relative "payload_verify"
18
+
16
19
  module Rail0
17
20
  # EIP-712 and EIP-3009 signing utilities for RAIL0 payments.
18
21
  #
@@ -25,7 +28,7 @@ module Rail0
25
28
  # ## Typical usage (simplest path)
26
29
  #
27
30
  # resp = client.payments.create(chain_id: 84532, mode: "authorize", amount: "100.00", token: "0x...", payer: "0x...", payee: "0x...")
28
- # 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)
29
32
  # client.payments.sign(resp[:rail0_id], { signature: sig.to_hex })
30
33
  #
31
34
  module Signing
@@ -61,7 +64,7 @@ module Rail0
61
64
  # DEPRECATED — the parameter object of the removed {sign_authorize} /
62
65
  # {sign_charge}. Kept only so existing code reaches those methods' migration
63
66
  # message instead of a NameError while building their arguments; nothing in this
64
- # 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`.
65
68
  SignPaymentParams = Struct.new(
66
69
  :private_key, :payment, :nonce, :contract_address, :token_domain,
67
70
  keyword_init: true
@@ -233,25 +236,66 @@ module Rail0
233
236
  # (chain id, nonce, to, value, data, gas, fees) and the client assembles and
234
237
  # signs the transaction locally.
235
238
  #
236
- # tx = client.payments.authorize_prepare(rail0_id)
237
- # raw = Rail0::Signing.sign_transaction(tx[:unsigned_transaction], PAYER_PRIVATE_KEY)
238
- # client.payments.authorize(rail0_id, { signed_transaction: raw })
239
+ # With +payment:+ and +operation:+ the transaction is VERIFIED before the key
240
+ # touches it ({Rail0::TxVerify.verify}): zero value, the payment's chain and RAIL0
241
+ # contract, the key's own address as +from+, the RAIL0 function for +operation+,
242
+ # this payment's id in the calldata and — for capture and refund — the +amount:+ you
243
+ # asked for, scaled by the payment's +decimals+. A mismatch raises
244
+ # {Rail0::TxVerify::RefusedSignature} naming the field, and nothing is signed.
245
+ # Everything compared is already in hand, so this adds no gateway call.
246
+ #
247
+ # payment = client.payments.get(rail0_id) # chain_id, rail0_contract, rail0_id, decimals
248
+ # tx = client.payments.capture_prepare(rail0_id, "50.00")
249
+ # raw = Rail0::Signing.sign_transaction(tx[:unsigned_transaction], PAYEE_PRIVATE_KEY,
250
+ # payment: payment, operation: "capture", amount: "50.00")
251
+ # client.payments.capture(rail0_id, { signed_transaction: raw })
252
+ #
253
+ # Without +payment:+ the transaction is signed BLINDLY — a compromised or
254
+ # misconfigured endpoint could have this key sign an arbitrary transaction. That
255
+ # form is DEPRECATED (a +:deprecated+ warning is emitted; enable them with
256
+ # +Warning[:deprecated] = true+ or +-W:deprecated+) and will be removed in the next
257
+ # major version.
239
258
  #
240
259
  # @param unsigned_transaction [String, Hash] The +unsigned_transaction+ JSON
241
260
  # string from a prepare response (a pre-parsed Hash is also accepted).
242
- # @param private_key [String] Signer's private key (0x-prefixed or raw hex).
261
+ # @param private_key [String, Eth::Key] Signer's private key (0x-prefixed or raw hex).
262
+ # @param payment [Hash, nil] The payment record the transaction is for.
263
+ # @param operation [String, Symbol, nil] The operation the prepare was called for.
264
+ # @param amount [String, nil] The human decimal passed to the prepare (capture/refund).
243
265
  # @return [String] 0x-prefixed RLP-encoded signed transaction.
244
- def self.sign_transaction(unsigned_transaction, private_key)
266
+ def self.sign_transaction(unsigned_transaction, private_key, payment: nil, operation: nil, amount: nil)
267
+ key = private_key.is_a?(Eth::Key) ? private_key : Eth::Key.new(priv: private_key.delete_prefix("0x"))
268
+
269
+ if payment || operation
270
+ # Before the transaction is even assembled: one that fails the check must
271
+ # leave no signature behind.
272
+ TxVerify.verify(unsigned_transaction, payment: payment, operation: operation,
273
+ amount: amount, signer: key.address.to_s)
274
+ else
275
+ warn("Rail0::Signing.sign_transaction without payment:/operation: signs the gateway's " \
276
+ "transaction blindly and is deprecated; pass the payment record and the operation " \
277
+ "so it is verified first", uplevel: 1, category: :deprecated)
278
+ end
279
+
245
280
  f = unsigned_transaction.is_a?(String) ? JSON.parse(unsigned_transaction) : unsigned_transaction
246
281
  f = f.transform_keys(&:to_s)
247
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
+
248
292
  tx = Eth::Tx.new(
249
293
  chain_id: Integer(f.fetch("chain_id")),
250
294
  nonce: Integer(f.fetch("nonce")),
251
295
  priority_fee: Integer(f.fetch("max_priority_fee_per_gas")),
252
296
  max_gas_fee: Integer(f.fetch("max_fee_per_gas")),
253
297
  gas_limit: Integer(f.fetch("gas_limit")),
254
- to: f.fetch("to"),
298
+ to: "0x#{f['to'].to_s.strip[2..]}",
255
299
  value: Integer(f["value"] || 0),
256
300
  data: f["data"].to_s
257
301
  )
@@ -261,28 +305,83 @@ module Rail0
261
305
  # digest signature), so an arbitrary #sign(digest) object cannot serve here.
262
306
  # An Eth::Key is accepted so a caller holding one doesn't have to export it
263
307
  # back to a String.
264
- tx.sign(private_key.is_a?(Eth::Key) ? private_key : Eth::Key.new(priv: private_key.delete_prefix("0x")))
308
+ tx.sign(key)
265
309
  "0x#{tx.hex}"
266
310
  end
267
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
+
268
355
  # Sign the EIP-3009 payload using the signing_payload returned by POST /payments.
269
356
  #
270
- # This is the simplest entry point: pass the full signing_payload from the create response
271
- # and a private key — all fields are read directly from the payload without any manual
272
- # 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,
273
359
  # verifyingContract, validAfter, validBefore — which this reads directly.)
274
360
  #
275
- # resp = client.payments.create(
276
- # chain_id: 84532, mode: "authorize",
277
- # amount: "100.00", token: "0x...", payer: "0x...", payee: "0x..."
278
- # )
279
- # sig = Rail0::Signing.sign_payload(BUYER_PRIVATE_KEY, resp[:signing_payload])
280
- # 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.
281
367
  #
282
- # @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).
283
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).
284
372
  # @return [Eip3009Signature]
285
- 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)
286
385
  payload = symbolize(signing_payload)
287
386
  d = symbolize(payload[:domain])
288
387
  m = symbolize(payload[:message])
@@ -308,9 +407,11 @@ module Rail0
308
407
  )
309
408
  end
310
409
 
410
+ private_class_method :sign_verbatim
411
+
311
412
  # Sign a raw EIP-3009 transferWithAuthorization message.
312
413
  #
313
- # 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
314
415
  # API-returned signingPayload. Use this method only when you need full control over
315
416
  # the message fields (e.g. integrating with a contract directly).
316
417
  #
@@ -334,7 +435,7 @@ module Rail0
334
435
  )
335
436
  end
336
437
 
337
- # REMOVED — see {sign_payload}.
438
+ # REMOVED — see {sign_signing_payload}.
338
439
  #
339
440
  # These two rebuilt the EIP-3009 digest from a payment record: they read
340
441
  # from/value/validBefore off the payment, hardcoded `validAfter = 0` and `to =
@@ -355,7 +456,8 @@ module Rail0
355
456
  "Rail0::Signing.%s was removed: it rebuilt the EIP-3009 digest from a payment " \
356
457
  "record, which signs the WRONG digest as soon as the contract's payload changes " \
357
458
  "(rail0#58 moves authorize/charge to ReceiveWithAuthorization). Use " \
358
- "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) — " \
359
461
  "the gateway builds the payload, clients sign it verbatim."
360
462
  private_constant :REMOVED_MESSAGE
361
463
 
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rail0
6
+ # Pre-sign verification of a gateway-built unsigned transaction.
7
+ #
8
+ # +Rail0::Signing.sign_transaction+ used to sign whatever +unsigned_transaction+ a
9
+ # prepare step returned. A hostile or merely misconfigured gateway endpoint could
10
+ # therefore have the merchant's own key sign an arbitrary transaction — an
11
+ # +approve(spender, MAX)+ on the token, a capture answered to a void prepare, a
12
+ # capture for a larger amount than asked. The checks here run on data the caller
13
+ # already holds (the payment record, the operation it asked for, the amount it typed)
14
+ # BEFORE the key touches the transaction, so they add no gateway round-trip. A
15
+ # mismatch raises {RefusedSignature}, whose message starts with "refusing to sign:"
16
+ # and names the field; no signature is produced.
17
+ #
18
+ # Ported from the rail0 CLI's cmd/tx_verify.go (#63), which is the reference for the
19
+ # check order and the fail-closed rules. Loaded by +rail0/signing+ (it needs the
20
+ # +eth+ gem for keccak256), never by +require "rail0"+ alone.
21
+ module TxVerify
22
+ # Raised instead of signing. Deliberately not an ArgumentError: the caller's
23
+ # arguments are fine, it is the GATEWAY's transaction that is not.
24
+ class RefusedSignature < StandardError; end
25
+
26
+ # RAIL0.sol's +Payment+ struct as the ABI encodes it: payer, payee, token, amount
27
+ # (uint120), authorizationExpiry and refundExpiry (uint48). All static types, so the
28
+ # tuple is encoded INLINE — six 32-byte words right after the paymentId — which is
29
+ # what fixes the position of the amount argument below.
30
+ PAYMENT_TUPLE = "(address,address,address,uint120,uint48,uint48)"
31
+ private_constant :PAYMENT_TUPLE
32
+
33
+ # The eight RAIL0 entry points, keyed by the gateway's wire name for the operation.
34
+ # Copied from RAIL0.sol; the spec pins the derived selectors against the contract's
35
+ # compiled +methodIdentifiers+, so a typo here cannot agree with itself.
36
+ FUNCTION_SIGNATURES = {
37
+ "authorize" => "authorize(bytes32,#{PAYMENT_TUPLE},uint8,bytes32,bytes32)",
38
+ "charge" => "charge(bytes32,#{PAYMENT_TUPLE},uint8,bytes32,bytes32)",
39
+ "capture" => "capture(bytes32,#{PAYMENT_TUPLE},uint256)",
40
+ "void" => "void(bytes32,#{PAYMENT_TUPLE})",
41
+ "release" => "release(bytes32,#{PAYMENT_TUPLE})",
42
+ "refund" => "refund(bytes32,#{PAYMENT_TUPLE},uint256,uint8,bytes32,bytes32)",
43
+ "dispute" => "dispute(bytes32,#{PAYMENT_TUPLE},bytes32)",
44
+ "close_dispute" => "closeDispute(bytes32,#{PAYMENT_TUPLE},bytes32)"
45
+ }.freeze
46
+
47
+ # Operation → the function's 4-byte selector as 8 lowercase hex chars: the first
48
+ # four bytes of keccak256(signature), computed from the table above rather than
49
+ # hand-copied.
50
+ SELECTORS = FUNCTION_SIGNATURES.transform_values do |sig|
51
+ Eth::Util.keccak256(sig)[0, 4].unpack1("H*")
52
+ end.freeze
53
+
54
+ # Calldata position (in 32-byte words after the selector) of the +uint256 amount+
55
+ # argument of capture and refund: word 0 is the paymentId, words 1-6 the inline
56
+ # Payment tuple, so the amount is word 7. Only these two operations take an amount.
57
+ AMOUNT_WORD = { "capture" => 7, "refund" => 7 }.freeze
58
+ private_constant :AMOUNT_WORD
59
+
60
+ SELECTOR_HEX = 8 # 4-byte selector
61
+ WORD_HEX = 64 # 32-byte ABI word
62
+ private_constant :SELECTOR_HEX, :WORD_HEX
63
+
64
+ # Folds the vocabularies an operation arrives in onto the gateway's wire name:
65
+ # "closeDispute", "close dispute", :close_dispute are one operation.
66
+ def self.canonical_operation(operation)
67
+ operation.to_s.strip
68
+ .gsub(/([a-z0-9])([A-Z])/, '\1_\2')
69
+ .gsub(/[\s-]+/, "_")
70
+ .downcase
71
+ end
72
+
73
+ # Verifies a gateway-built +unsigned_transaction+ against what the caller asked for,
74
+ # BEFORE any key touches it. Raises {RefusedSignature} naming the first field that
75
+ # disagrees; returns the parsed, string-keyed transaction otherwise.
76
+ #
77
+ # In order:
78
+ # 1. the JSON parses;
79
+ # 2. +value+ is zero — every RAIL0 operation is value-0, funds move as token
80
+ # transfers the contract performs, so a non-zero value is the gateway asking for ETH;
81
+ # 3. +chain_id+ is the payment's chain;
82
+ # 4. +from+, when present, is +signer+ (the signing key's address);
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;
86
+ # 6. the 4-byte selector is the RAIL0 function for +operation+, and for capture and
87
+ # refund the amount word equals +amount+ scaled by the payment's +decimals+;
88
+ # 7. the paymentId word is the payment's +rail0_id+.
89
+ #
90
+ # Every right-hand side is something the caller supplied or derived locally — never
91
+ # the prepare response itself. FAIL-CLOSED on what cannot be compared: an unknown
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,
94
+ # when the payment carries no usable +rail0_id+.
95
+ #
96
+ # @param unsigned_transaction [String, Hash] the prepare response's field-set
97
+ # @param payment [Hash] the payment record (from +payments.create+ / +payments.get+)
98
+ # @param operation [String, Symbol] the operation the prepare was called for
99
+ # @param amount [String, nil] the human decimal passed to the prepare (capture/refund)
100
+ # @param signer [String, nil] the address of the key about to sign, or nil to skip
101
+ # @return [Hash] the transaction, string-keyed
102
+ def self.verify(unsigned_transaction, payment:, operation:, amount: nil, signer: nil)
103
+ tx = parse(unsigned_transaction)
104
+ pay = payment_fields(payment)
105
+ check_header(tx, pay, signer)
106
+
107
+ data = tx["data"].to_s
108
+ # The operation before the payment id, so a swapped operation is named as such.
109
+ check_call(data, operation, expected_base_units(pay, operation, amount))
110
+ check_payment_id(data, pay[:rail0_id])
111
+ tx
112
+ end
113
+
114
+ # Checks 2-5: the fields outside the calldata.
115
+ def self.check_header(fields, pay, signer)
116
+ unless zero_native_value?(fields["value"])
117
+ refuse("transaction attaches native currency (value=#{fields['value'].inspect}); " \
118
+ "every RAIL0 operation is value-0")
119
+ end
120
+
121
+ if pay[:chain_id] && !pay[:chain_id].to_i.zero? && fields["chain_id"].to_s != pay[:chain_id].to_s
122
+ refuse("transaction targets chain #{fields['chain_id']} but this payment is on chain #{pay[:chain_id]}")
123
+ end
124
+
125
+ from = fields["from"].to_s
126
+ if signer && !from.empty? && !same_address?(from, signer)
127
+ refuse("transaction is prepared from #{from} but the signing key is #{signer}")
128
+ end
129
+
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)
149
+
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)
158
+ end
159
+
160
+ def self.parse(unsigned_transaction)
161
+ tx = unsigned_transaction.is_a?(String) ? JSON.parse(unsigned_transaction) : unsigned_transaction
162
+ refuse("the unsigned transaction is not an object") unless tx.is_a?(Hash)
163
+ tx.transform_keys(&:to_s)
164
+ rescue JSON::ParserError => e
165
+ refuse("the gateway's unsigned transaction is not valid JSON: #{e.message}")
166
+ end
167
+
168
+ def self.payment_fields(payment)
169
+ refuse("cannot check the transaction without the payment it is for") unless payment.is_a?(Hash)
170
+ p = payment.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
171
+ { chain_id: p[:chain_id], rail0_id: p[:rail0_id], rail0_contract: presence(p[:rail0_contract]),
172
+ decimals: p[:decimals] }
173
+ end
174
+
175
+ def self.presence(value)
176
+ s = value.to_s
177
+ s.empty? ? nil : s
178
+ end
179
+
180
+ private_class_method :check_header, :check_to, :parse, :payment_fields, :presence
181
+
182
+ def self.refuse(message)
183
+ raise RefusedSignature, "refusing to sign: #{message}"
184
+ end
185
+
186
+ # The encodings a zero native value legitimately arrives in — absent, nil, the
187
+ # number 0, or the string "0" / "0x0". A whitelist, deliberately: anything
188
+ # unrecognised must refuse the signature rather than be guessed at.
189
+ def self.zero_native_value?(value)
190
+ case value
191
+ when nil then true
192
+ when Integer then value.zero?
193
+ when String
194
+ s = value.strip
195
+ s.empty? || s == "0" || s.casecmp("0x0").zero?
196
+ else false
197
+ end
198
+ end
199
+
200
+ def self.same_address?(left, right)
201
+ left.to_s.casecmp(right.to_s).zero?
202
+ end
203
+
204
+ private_class_method :refuse, :zero_native_value?, :same_address?
205
+
206
+ # Scales the human amount the CALLER typed by the payment's token decimals, for
207
+ # comparison against the calldata. nil when the operation takes no amount. For one
208
+ # that does, a missing amount or unknown decimals is a refusal — the whole guard
209
+ # exists for that comparison, so it must not be skipped.
210
+ def self.expected_base_units(pay, operation, amount)
211
+ key = canonical_operation(operation)
212
+ return nil unless AMOUNT_WORD.key?(key)
213
+
214
+ refuse("cannot check the #{key} amount without the amount you asked for") if amount.to_s.strip.empty?
215
+ if pay[:decimals].nil?
216
+ refuse("the payment's token decimals are unknown, so the #{key} amount cannot be checked " \
217
+ "(fetch the payment with payments.get first)")
218
+ end
219
+ to_base_units(amount.to_s, Integer(pay[:decimals]))
220
+ end
221
+
222
+ # String math, no Float: "1.5" at 6 decimals is "1500000". More fractional digits
223
+ # than the token has is a refusal, not a truncation — the gateway 422s such an
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+
231
+ def self.to_base_units(human, decimals)
232
+ s = human.strip
233
+ refuse("amount #{human.inspect} is not a decimal number") unless s.match?(/\A\d+(\.\d+)?\z/)
234
+ whole, frac = s.split(".", 2)
235
+ frac ||= ""
236
+ if frac.length > decimals
237
+ refuse("amount #{human.inspect} has #{frac.length} decimal places, but this token has #{decimals}")
238
+ end
239
+ (whole + frac.ljust(decimals, "0")).sub(/\A0+(?=\d)/, "")
240
+ end
241
+
242
+ private_class_method :expected_base_units
243
+
244
+ # Asserts the call is the RAIL0 function for +operation+ and, for capture and refund,
245
+ # that it moves exactly +want_units+ base units.
246
+ #
247
+ # Checking the paymentId alone binds the transaction to the right PAYMENT but not to
248
+ # the right OPERATION: every RAIL0 function takes the paymentId first, so a hostile
249
+ # endpoint could answer a void prepare with capture calldata (settling the escrow
250
+ # to the merchant instead of returning it). The selector closes that; the amount
251
+ # word closes the remaining capture/refund swap — the right function for a
252
+ # different quantity than the caller typed. Fail-closed: no known selector, or an
253
+ # amount-taking operation with nothing local to compare, refuses rather than skips.
254
+ def self.check_call(data, operation, want_units)
255
+ key = canonical_operation(operation)
256
+ want = SELECTORS[key] || refuse("no known RAIL0 function for operation #{operation.to_s.inspect}")
257
+ raw = strip_hex(data)
258
+ refuse("calldata is #{raw.length} hex chars, too short to carry a function selector") if raw.length < SELECTOR_HEX
259
+ got = raw[0, SELECTOR_HEX]
260
+ if got != want
261
+ refuse("the call is #{describe_selector(got)} but you asked to #{key} (#{FUNCTION_SIGNATURES[key]})")
262
+ end
263
+
264
+ word = AMOUNT_WORD[key]
265
+ return if word.nil?
266
+
267
+ refuse("cannot check the #{key} amount without the amount you asked for") if want_units.nil?
268
+ start = SELECTOR_HEX + (word * WORD_HEX)
269
+ if raw.length < start + WORD_HEX
270
+ refuse("calldata is #{raw.length} hex chars, too short to carry the #{key} amount")
271
+ end
272
+ got_int = raw[start, WORD_HEX].to_i(16)
273
+ want_int = Integer(want_units, 10)
274
+ return if got_int == want_int
275
+
276
+ refuse("the call would #{key} #{got_int} base units but you asked for #{want_int}")
277
+ end
278
+
279
+ # Names a selector for an error message: the RAIL0 function it belongs to when it
280
+ # is one of ours (the likely shape of a swapped operation), and the bare hex otherwise.
281
+ def self.describe_selector(selector)
282
+ op = SELECTORS.key(selector)
283
+ op ? "#{FUNCTION_SIGNATURES[op].split('(', 2).first}()" : "selector 0x#{selector}"
284
+ end
285
+
286
+ # Asserts the call's first argument is THIS payment's id.
287
+ #
288
+ # All eight RAIL0 operations take +bytes32 paymentId+ first, so under ABI encoding
289
+ # the id always occupies bytes 4..36 of the calldata. A hostile gateway's most
290
+ # profitable swap is +approve(spender, MAX)+ on the token contract, which this
291
+ # rejects: approve's first argument is a left-padded address, so it cannot be a
292
+ # rail0_id. Skipped only when there is no usable rail0_id to compare against.
293
+ def self.check_payment_id(data, rail0_id)
294
+ want = strip_hex(rail0_id.to_s)
295
+ return if want.length != WORD_HEX
296
+
297
+ raw = strip_hex(data)
298
+ if raw.length < SELECTOR_HEX + WORD_HEX
299
+ refuse("calldata is #{raw.length} hex chars, too short to carry a paymentId")
300
+ end
301
+ got = raw[SELECTOR_HEX, WORD_HEX]
302
+ refuse("the call acts on payment 0x#{got} but you asked for 0x#{want}") if got != want
303
+ end
304
+
305
+ def self.strip_hex(hex)
306
+ hex.to_s.strip.downcase.delete_prefix("0x")
307
+ end
308
+
309
+ private_class_method :check_call, :describe_selector, :check_payment_id, :strip_hex
310
+ end
311
+ end
data/lib/rail0/types.rb CHANGED
@@ -348,20 +348,83 @@ module Rail0
348
348
  keyword_init: true
349
349
  )
350
350
 
351
- # The whole account record, as an operator reads it (Full entity: GET /accounts, POST /accounts, and
352
- # GET/PATCH /accounts/{account_id} for an admin session): every column plus `admin` and the derived
353
- # `status`.
351
+ # The whole account record, as an operator reads it (Full entity: GET /accounts, and GET/PATCH
352
+ # /accounts/{account_id} for an admin session): every column plus `admin` and the derived `status`.
354
353
  AccountFull = Struct.new(
355
354
  :id, # String
356
355
  :name, # String
357
356
  :email, # String — Stored lowercase.
358
357
  :active, # Boolean — The operator's switch. false = deactivated.
359
358
  :priority_tip, # Boolean — The merchant's fee setting (see Account.priority_tip).
360
- :claimed_at, # When the merchant claimed the account at POST /claim; null while it is pending its claim.
361
359
  :created_at, # String
362
360
  :updated_at, # String
363
361
  :admin, # Boolean — Whether the account holds the operator grant (a row in administrators).
364
- :status, # String — One-word reading of active and claimed_at together: pending_claim (not yet claimed, not operational), active (claimed and active) or deactivated (switched off by an operator, claimed or not).
362
+ :status, # String — One-word reading of active: active, or deactivated (switched off by an operator).
363
+ keyword_init: true
364
+ )
365
+
366
+ # An onboarding request (Full entity): every column plus the derived `status` and the `creator`.
367
+ # Never a token or a link: the token is not stored.
368
+ Onboarding = Struct.new(
369
+ :id, # String — Also the id of the account the claim creates.
370
+ :origin, # String
371
+ :name, # String
372
+ :email, # String — Lowercase.
373
+ :address, # String — Lowercase 0x + 40 hex.
374
+ :created_by, # The operator account of an invite; null for a sign-up.
375
+ :link_version, # Integer — The version of the last link minted; a link for any lower one answers onboarding_link_superseded.
376
+ :last_sent_at, # String — When the last send was queued.
377
+ :send_count, # Integer — Sends queued in all (0 for a request migrated from a pending account, which has no link in the current format yet).
378
+ :last_delivered_at, # When the provider accepted the last send.
379
+ :last_send_error, # A short fixed code for why the last send failed (provider_rejected, timeout, email_domain_not_allowed, …); null when it was delivered or is in flight.
380
+ :expires_at, # String — When the last link, and the open request, expire.
381
+ :completed_at, # When the claim created the account.
382
+ :terms_version, # The terms version accepted at the claim.
383
+ :created_at, # String
384
+ :updated_at, # String
385
+ :status, # String
386
+ :creator, # The operator who created an invite; null for a sign-up.
387
+ keyword_init: true
388
+ )
389
+
390
+ # A clash of a request with an account (the value already belongs to it) or with another request not
391
+ # yet completed.
392
+ OnboardingConflict = Struct.new(
393
+ :field, # String
394
+ :with, # String
395
+ :id, # String — The account's or the other request's id.
396
+ keyword_init: true
397
+ )
398
+
399
+ # The mail queued for the request — never its link, which exists only in the mail.
400
+ QueuedEmail = Struct.new(
401
+ :kind, # String
402
+ :to, # String
403
+ :queued, # Boolean
404
+ keyword_init: true
405
+ )
406
+
407
+ # An operator's create, reuse or correction.
408
+ OnboardingOutcome = Struct.new(
409
+ :onboarding, # Onboarding
410
+ :conflicts, # Array
411
+ :email, # QueuedEmail
412
+ keyword_init: true
413
+ )
414
+
415
+ # A resend or reminder.
416
+ OnboardingSent = Struct.new(
417
+ :onboarding, # Onboarding
418
+ :email, # QueuedEmail
419
+ keyword_init: true
420
+ )
421
+
422
+ # The mail as it would have been sent, rendered by the gateway's template with `<claim link
423
+ # generated after the request is created>` in place of the link.
424
+ EmailPreview = Struct.new(
425
+ :to, # String
426
+ :subject, # String
427
+ :text, # String
365
428
  keyword_init: true
366
429
  )
367
430
 
@@ -414,6 +477,8 @@ module Rail0
414
477
  :siwe, # Hash
415
478
  :fees, # Hash
416
479
  :rpc, # Hash
480
+ :onboarding, # Hash
481
+ :email, # Hash
417
482
  :generated_at, # String
418
483
  keyword_init: true
419
484
  )
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.5.1"
8
+ VERSION = "1.7.0"
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.5.1
4
+ version: 1.7.0
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-16 00:00:00.000000000 Z
11
+ date: 2026-10-19 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
@@ -59,6 +60,7 @@ files:
59
60
  - lib/rail0/resources/webhooks.rb
60
61
  - lib/rail0/signing.rb
61
62
  - lib/rail0/stablecoins.rb
63
+ - lib/rail0/tx_verify.rb
62
64
  - lib/rail0/types.rb
63
65
  - lib/rail0/version.rb
64
66
  - lib/rail0/webhook_signature.rb