rail0-sdk 1.5.1 → 1.6.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: e046261654c0f0d93915dd0ac7003e78f224115748e697581cac6020c4ea4003
4
+ data.tar.gz: f9fea94c35a6b996732e0bbdc538361a229134edaa4f52bc6a010e1dfd55b871
5
5
  SHA512:
6
- metadata.gz: b0e50c24f6276cb4ba23c812dcc8bc7f3b58cf92715855518bb0a76725c3393959487eb29e59794312bd0ccaab49148b436d5c1873cdeb5029db32f5f5a15ef1
7
- data.tar.gz: 59463fad22c007aea05412ade646a21c051db20a85f886bc62897442c810f3c1d3d0f295f03c252c691661be958e8d3f729e7aacbdc0af4a7613a5d13e626483
6
+ metadata.gz: dcb35d580188aef4a5e6b36878e6e8b3da40dddd671a792eb3275f4baad286636ec6f00e6284d1a0263c6ec764d539e17d8c85ea1904b77f3f1326e8929174fd
7
+ data.tar.gz: 592f3db30a8c9ecdc755f5b64e9dc0a69166ca73f6b51efcae2c348fb28e60d39b148b41b3cd54015012837c7a8987583d3bc6621f6ef82a4bca3e8b14381526
data/README.md CHANGED
@@ -82,8 +82,12 @@ sig = Rail0::Signing.sign_payload(BUYER_PRIVATE_KEY, payment[:signing_payload])
82
82
  client.payments.sign(rail0_id, { signature: sig.to_hex })
83
83
 
84
84
  # 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)
85
+ # payment:/operation: are what the gateway's transaction is CHECKED against before
86
+ # the key signs it (see "Payment lifecycle"); `payment` is the record read from the gateway.
87
+ payment = client.payments.get(rail0_id)
88
+ prep = client.payments.authorize_prepare(rail0_id)
89
+ raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], MERCHANT_PRIVATE_KEY,
90
+ payment: payment, operation: "authorize")
87
91
  client.payments.authorize(rail0_id, { signed_transaction: raw }) # HTTP 202 (async)
88
92
 
89
93
  # 4. Poll until the authorization confirms.
@@ -95,7 +99,8 @@ end
95
99
 
96
100
  # 5. Payee captures the escrowed funds (partial capture is supported).
97
101
  cap = client.payments.capture_prepare(rail0_id, "50.00")
98
- raw = Rail0::Signing.sign_transaction(cap[:unsigned_transaction], MERCHANT_PRIVATE_KEY)
102
+ raw = Rail0::Signing.sign_transaction(cap[:unsigned_transaction], MERCHANT_PRIVATE_KEY,
103
+ payment: payment, operation: "capture", amount: "50.00")
99
104
  client.payments.capture(rail0_id, { signed_transaction: raw })
100
105
  ```
101
106
 
@@ -108,12 +113,34 @@ Each on-chain operation is a `prepare` → `submit` pair:
108
113
 
109
114
  1. **prepare** — `client.payments.<op>_prepare(...)` returns a transaction whose
110
115
  `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.
116
+ 2. **sign** — `Rail0::Signing.sign_transaction(unsigned, private_key, payment:, operation:, amount:)`
117
+ verifies the transaction against the payment record, then returns the signed raw tx.
113
118
  3. **submit** — `client.payments.<op>(id, { signed_transaction: raw })` broadcasts
114
119
  it. The gateway acknowledges with HTTP 202 and confirms asynchronously — poll
115
120
  `client.payments.get(id)` until the status settles.
116
121
 
122
+ **What `sign_transaction` verifies, and why.** The gateway builds the EIP-1559
123
+ transaction and your key signs it, so a compromised or misconfigured endpoint could
124
+ otherwise have that key sign anything — an `approve(spender, MAX)` on the token, a
125
+ `capture` answered to a `void` prepare, a capture for more than you asked. Given
126
+ `payment:` (the record from `payments.get`) and `operation:`, `sign_transaction` checks
127
+ the transaction **before** the key touches it, against data you already hold (no extra
128
+ 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`,
130
+ the 4-byte selector is the RAIL0 function for `operation:` (the selectors are derived
131
+ from the contract's function signatures, `Rail0::TxVerify::SELECTORS`, and pinned
132
+ against its ABI in the spec suite), the first calldata word is the payment's `rail0_id`,
133
+ and — for `capture` and `refund` — the amount word equals `amount:` scaled by the
134
+ payment's `decimals`. The first mismatch raises `Rail0::TxVerify::RefusedSignature`
135
+ ("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
138
+ gateway — which is why the calldata binding to this payment's id and operation is what
139
+ closes a swapped transaction. Calling `sign_transaction` without `payment:` still signs
140
+ blindly and is **deprecated** (a `:deprecated`-category warning; shown with
141
+ `-W:deprecated`). `Rail0::TxVerify.verify` runs the check on its own, for a signer that
142
+ is not a local key.
143
+
117
144
  Wallets that sign **and** broadcast in one step (e.g. MetaMask via
118
145
  `eth_sendTransaction`) skip steps 2–3 and report the hash instead:
119
146
 
@@ -466,7 +493,8 @@ sig = Rail0::Signing.sign_payload(MERCHANT_PRIVATE_KEY, p1[:signing_payload])
466
493
 
467
494
  # Phase 2 — amount + signature → returns the unsigned on-chain tx.
468
495
  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)
496
+ raw = Rail0::Signing.sign_transaction(p2[:unsigned_transaction], MERCHANT_PRIVATE_KEY,
497
+ payment: payment, operation: "refund", amount: "20.00")
470
498
  client.payments.refund(rail0_id, { signed_transaction: raw })
471
499
  ```
472
500
 
@@ -479,12 +507,15 @@ pattern. Like the rest of `/payments` they need a session, but the payer's
479
507
  account-less SIWE login is enough:
480
508
 
481
509
  ```ruby
510
+ payment = client.payments.get(rail0_id)
482
511
  prep = client.payments.dispute_prepare(rail0_id, reason: "not_received") # reason optional
483
- raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], BUYER_PRIVATE_KEY)
512
+ raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], BUYER_PRIVATE_KEY,
513
+ payment: payment, operation: "dispute")
484
514
  client.payments.dispute(rail0_id, { signed_transaction: raw })
485
515
  # … later …
486
516
  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)
517
+ raw = Rail0::Signing.sign_transaction(prep[:unsigned_transaction], BUYER_PRIVATE_KEY,
518
+ payment: payment, operation: "close_dispute")
488
519
  client.payments.close_dispute(rail0_id, { signed_transaction: raw })
489
520
  ```
490
521
 
@@ -765,7 +796,8 @@ Requires the `eth` gem. No private key ever leaves your process.
765
796
  | Method | Use |
766
797
  |--------|-----|
767
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 |
768
- | `sign_transaction(unsigned_transaction, key)` | Sign a prepare step's unsigned EIP-1559 transaction; returns the 0x raw tx |
799
+ | `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
+ | `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
801
  | `sign_transfer_with_authorization(signer, domain, params)` | Raw EIP-3009 `TransferWithAuthorization` signer, for talking to a token directly |
770
802
 
771
803
  ```ruby
@@ -20,7 +20,7 @@ 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
26
  # expectation: that endpoint is unauthenticated, so echoing the allow-list or the
@@ -79,6 +79,21 @@ module Rail0
79
79
  "unknown_token" => "the token isn't configured on this chain",
80
80
  "no_active_contract" => "no active RAIL0 contract on that chain",
81
81
  "missing_param" => "a required parameter is missing from the request",
82
+ # Onboarding: POST /signup and POST /claim (the gateway's onboarding flow).
83
+ # The /admin/onboardings codes (onboarding_not_found, onboarding_completed) are left
84
+ # out: those routes are the console's, not the SDK's. The link codes never say which
85
+ # term failed — a stale link has one remedy: the newest email, or a new link.
86
+ "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",
87
+ "onboarding_unavailable" => "this gateway has no onboarding secret configured, so it can neither issue nor verify sign-up links — ask the operator",
88
+ "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",
89
+ "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",
90
+ "account_email_taken" => "an account with this email already exists — sign in with its wallet, or use another email",
91
+ "account_wallet_taken" => "this wallet is already registered to an account — sign in with it, or use another wallet",
92
+ "account_name_taken" => "an account with this name already exists — choose another name",
93
+ "terms_required" => "the claim needs terms_version: send the version of the terms the merchant accepted",
94
+ "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",
95
+ "onboarding_link_expired" => "the link has expired — sign up again, or ask your RAIL0 contact for a new link",
96
+ "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
97
  # A BARE forbidden is not a party mismatch: the gateway split those into codes of
83
98
  # their own (not_the_payee, not_the_payer, wallet_deactivated, account_deactivated,
84
99
  # not_your_account) because they need different fixes. This entry kept describing
data/lib/rail0/signing.rb CHANGED
@@ -13,6 +13,8 @@ ensure
13
13
  $VERBOSE = original_verbose
14
14
  end
15
15
 
16
+ require_relative "tx_verify"
17
+
16
18
  module Rail0
17
19
  # EIP-712 and EIP-3009 signing utilities for RAIL0 payments.
18
20
  #
@@ -233,15 +235,47 @@ module Rail0
233
235
  # (chain id, nonce, to, value, data, gas, fees) and the client assembles and
234
236
  # signs the transaction locally.
235
237
  #
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 })
238
+ # With +payment:+ and +operation:+ the transaction is VERIFIED before the key
239
+ # touches it ({Rail0::TxVerify.verify}): zero value, the payment's chain and RAIL0
240
+ # contract, the key's own address as +from+, the RAIL0 function for +operation+,
241
+ # this payment's id in the calldata and — for capture and refund — the +amount:+ you
242
+ # asked for, scaled by the payment's +decimals+. A mismatch raises
243
+ # {Rail0::TxVerify::RefusedSignature} naming the field, and nothing is signed.
244
+ # Everything compared is already in hand, so this adds no gateway call.
245
+ #
246
+ # payment = client.payments.get(rail0_id) # chain_id, rail0_contract, rail0_id, decimals
247
+ # tx = client.payments.capture_prepare(rail0_id, "50.00")
248
+ # raw = Rail0::Signing.sign_transaction(tx[:unsigned_transaction], PAYEE_PRIVATE_KEY,
249
+ # payment: payment, operation: "capture", amount: "50.00")
250
+ # client.payments.capture(rail0_id, { signed_transaction: raw })
251
+ #
252
+ # Without +payment:+ the transaction is signed BLINDLY — a compromised or
253
+ # misconfigured endpoint could have this key sign an arbitrary transaction. That
254
+ # form is DEPRECATED (a +:deprecated+ warning is emitted; enable them with
255
+ # +Warning[:deprecated] = true+ or +-W:deprecated+) and will be removed in the next
256
+ # major version.
239
257
  #
240
258
  # @param unsigned_transaction [String, Hash] The +unsigned_transaction+ JSON
241
259
  # 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).
260
+ # @param private_key [String, Eth::Key] Signer's private key (0x-prefixed or raw hex).
261
+ # @param payment [Hash, nil] The payment record the transaction is for.
262
+ # @param operation [String, Symbol, nil] The operation the prepare was called for.
263
+ # @param amount [String, nil] The human decimal passed to the prepare (capture/refund).
243
264
  # @return [String] 0x-prefixed RLP-encoded signed transaction.
244
- def self.sign_transaction(unsigned_transaction, private_key)
265
+ def self.sign_transaction(unsigned_transaction, private_key, payment: nil, operation: nil, amount: nil)
266
+ key = private_key.is_a?(Eth::Key) ? private_key : Eth::Key.new(priv: private_key.delete_prefix("0x"))
267
+
268
+ if payment || operation
269
+ # Before the transaction is even assembled: one that fails the check must
270
+ # leave no signature behind.
271
+ TxVerify.verify(unsigned_transaction, payment: payment, operation: operation,
272
+ amount: amount, signer: key.address.to_s)
273
+ else
274
+ warn("Rail0::Signing.sign_transaction without payment:/operation: signs the gateway's " \
275
+ "transaction blindly and is deprecated; pass the payment record and the operation " \
276
+ "so it is verified first", uplevel: 1, category: :deprecated)
277
+ end
278
+
245
279
  f = unsigned_transaction.is_a?(String) ? JSON.parse(unsigned_transaction) : unsigned_transaction
246
280
  f = f.transform_keys(&:to_s)
247
281
 
@@ -261,7 +295,7 @@ module Rail0
261
295
  # digest signature), so an arbitrary #sign(digest) object cannot serve here.
262
296
  # An Eth::Key is accepted so a caller holding one doesn't have to export it
263
297
  # back to a String.
264
- tx.sign(private_key.is_a?(Eth::Key) ? private_key : Eth::Key.new(priv: private_key.delete_prefix("0x")))
298
+ tx.sign(key)
265
299
  "0x#{tx.hex}"
266
300
  end
267
301
 
@@ -0,0 +1,280 @@
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 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;
85
+ # 6. the 4-byte selector is the RAIL0 function for +operation+, and for capture and
86
+ # refund the amount word equals +amount+ scaled by the payment's +decimals+;
87
+ # 7. the paymentId word is the payment's +rail0_id+.
88
+ #
89
+ # Every right-hand side is something the caller supplied or derived locally — never
90
+ # 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,
93
+ # when the payment carries no usable +rail0_id+.
94
+ #
95
+ # @param unsigned_transaction [String, Hash] the prepare response's field-set
96
+ # @param payment [Hash] the payment record (from +payments.create+ / +payments.get+)
97
+ # @param operation [String, Symbol] the operation the prepare was called for
98
+ # @param amount [String, nil] the human decimal passed to the prepare (capture/refund)
99
+ # @param signer [String, nil] the address of the key about to sign, or nil to skip
100
+ # @return [Hash] the transaction, string-keyed
101
+ def self.verify(unsigned_transaction, payment:, operation:, amount: nil, signer: nil)
102
+ tx = parse(unsigned_transaction)
103
+ pay = payment_fields(payment)
104
+ check_header(tx, pay, signer)
105
+
106
+ data = tx["data"].to_s
107
+ # The operation before the payment id, so a swapped operation is named as such.
108
+ check_call(data, operation, expected_base_units(pay, operation, amount))
109
+ check_payment_id(data, pay[:rail0_id])
110
+ tx
111
+ end
112
+
113
+ # Checks 2-5: the fields outside the calldata.
114
+ def self.check_header(fields, pay, signer)
115
+ unless zero_native_value?(fields["value"])
116
+ refuse("transaction attaches native currency (value=#{fields['value'].inspect}); " \
117
+ "every RAIL0 operation is value-0")
118
+ end
119
+
120
+ if pay[:chain_id] && !pay[:chain_id].to_i.zero? && fields["chain_id"].to_s != pay[:chain_id].to_s
121
+ refuse("transaction targets chain #{fields['chain_id']} but this payment is on chain #{pay[:chain_id]}")
122
+ end
123
+
124
+ from = fields["from"].to_s
125
+ if signer && !from.empty? && !same_address?(from, signer)
126
+ refuse("transaction is prepared from #{from} but the signing key is #{signer}")
127
+ end
128
+
129
+ to = fields["to"].to_s
130
+ return unless pay[:rail0_contract] && !to.empty? && !same_address?(to, pay[:rail0_contract])
131
+
132
+ refuse("transaction is addressed to #{to} but this payment's RAIL0 contract is #{pay[:rail0_contract]}")
133
+ end
134
+
135
+ def self.parse(unsigned_transaction)
136
+ tx = unsigned_transaction.is_a?(String) ? JSON.parse(unsigned_transaction) : unsigned_transaction
137
+ refuse("the unsigned transaction is not an object") unless tx.is_a?(Hash)
138
+ tx.transform_keys(&:to_s)
139
+ rescue JSON::ParserError => e
140
+ refuse("the gateway's unsigned transaction is not valid JSON: #{e.message}")
141
+ end
142
+
143
+ def self.payment_fields(payment)
144
+ refuse("cannot check the transaction without the payment it is for") unless payment.is_a?(Hash)
145
+ p = payment.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
146
+ { chain_id: p[:chain_id], rail0_id: p[:rail0_id], rail0_contract: presence(p[:rail0_contract]),
147
+ decimals: p[:decimals] }
148
+ end
149
+
150
+ def self.presence(value)
151
+ s = value.to_s
152
+ s.empty? ? nil : s
153
+ end
154
+
155
+ private_class_method :check_header, :parse, :payment_fields, :presence
156
+
157
+ def self.refuse(message)
158
+ raise RefusedSignature, "refusing to sign: #{message}"
159
+ end
160
+
161
+ # The encodings a zero native value legitimately arrives in — absent, nil, the
162
+ # number 0, or the string "0" / "0x0". A whitelist, deliberately: anything
163
+ # unrecognised must refuse the signature rather than be guessed at.
164
+ def self.zero_native_value?(value)
165
+ case value
166
+ when nil then true
167
+ when Integer then value.zero?
168
+ when String
169
+ s = value.strip
170
+ s.empty? || s == "0" || s.casecmp("0x0").zero?
171
+ else false
172
+ end
173
+ end
174
+
175
+ def self.same_address?(left, right)
176
+ left.to_s.casecmp(right.to_s).zero?
177
+ end
178
+
179
+ private_class_method :refuse, :zero_native_value?, :same_address?
180
+
181
+ # Scales the human amount the CALLER typed by the payment's token decimals, for
182
+ # comparison against the calldata. nil when the operation takes no amount. For one
183
+ # that does, a missing amount or unknown decimals is a refusal — the whole guard
184
+ # exists for that comparison, so it must not be skipped.
185
+ def self.expected_base_units(pay, operation, amount)
186
+ key = canonical_operation(operation)
187
+ return nil unless AMOUNT_WORD.key?(key)
188
+
189
+ refuse("cannot check the #{key} amount without the amount you asked for") if amount.to_s.strip.empty?
190
+ if pay[:decimals].nil?
191
+ refuse("the payment's token decimals are unknown, so the #{key} amount cannot be checked " \
192
+ "(fetch the payment with payments.get first)")
193
+ end
194
+ to_base_units(amount.to_s, Integer(pay[:decimals]))
195
+ end
196
+
197
+ # String math, no Float: "1.5" at 6 decimals is "1500000". More fractional digits
198
+ # than the token has is a refusal, not a truncation — the gateway 422s such an
199
+ # amount, so dropping digits would compare against a figure never asked for.
200
+ def self.to_base_units(human, decimals)
201
+ s = human.strip
202
+ refuse("amount #{human.inspect} is not a decimal number") unless s.match?(/\A\d+(\.\d+)?\z/)
203
+ whole, frac = s.split(".", 2)
204
+ frac ||= ""
205
+ if frac.length > decimals
206
+ refuse("amount #{human.inspect} has #{frac.length} decimal places, but this token has #{decimals}")
207
+ end
208
+ (whole + frac.ljust(decimals, "0")).sub(/\A0+(?=\d)/, "")
209
+ end
210
+
211
+ private_class_method :expected_base_units, :to_base_units
212
+
213
+ # Asserts the call is the RAIL0 function for +operation+ and, for capture and refund,
214
+ # that it moves exactly +want_units+ base units.
215
+ #
216
+ # Checking the paymentId alone binds the transaction to the right PAYMENT but not to
217
+ # the right OPERATION: every RAIL0 function takes the paymentId first, so a hostile
218
+ # endpoint could answer a void prepare with capture calldata (settling the escrow
219
+ # to the merchant instead of returning it). The selector closes that; the amount
220
+ # word closes the remaining capture/refund swap — the right function for a
221
+ # different quantity than the caller typed. Fail-closed: no known selector, or an
222
+ # amount-taking operation with nothing local to compare, refuses rather than skips.
223
+ def self.check_call(data, operation, want_units)
224
+ key = canonical_operation(operation)
225
+ want = SELECTORS[key] || refuse("no known RAIL0 function for operation #{operation.to_s.inspect}")
226
+ raw = strip_hex(data)
227
+ refuse("calldata is #{raw.length} hex chars, too short to carry a function selector") if raw.length < SELECTOR_HEX
228
+ got = raw[0, SELECTOR_HEX]
229
+ if got != want
230
+ refuse("the call is #{describe_selector(got)} but you asked to #{key} (#{FUNCTION_SIGNATURES[key]})")
231
+ end
232
+
233
+ word = AMOUNT_WORD[key]
234
+ return if word.nil?
235
+
236
+ refuse("cannot check the #{key} amount without the amount you asked for") if want_units.nil?
237
+ start = SELECTOR_HEX + (word * WORD_HEX)
238
+ if raw.length < start + WORD_HEX
239
+ refuse("calldata is #{raw.length} hex chars, too short to carry the #{key} amount")
240
+ end
241
+ got_int = raw[start, WORD_HEX].to_i(16)
242
+ want_int = Integer(want_units, 10)
243
+ return if got_int == want_int
244
+
245
+ refuse("the call would #{key} #{got_int} base units but you asked for #{want_int}")
246
+ end
247
+
248
+ # Names a selector for an error message: the RAIL0 function it belongs to when it
249
+ # is one of ours (the likely shape of a swapped operation), and the bare hex otherwise.
250
+ def self.describe_selector(selector)
251
+ op = SELECTORS.key(selector)
252
+ op ? "#{FUNCTION_SIGNATURES[op].split('(', 2).first}()" : "selector 0x#{selector}"
253
+ end
254
+
255
+ # Asserts the call's first argument is THIS payment's id.
256
+ #
257
+ # All eight RAIL0 operations take +bytes32 paymentId+ first, so under ABI encoding
258
+ # the id always occupies bytes 4..36 of the calldata. A hostile gateway's most
259
+ # profitable swap is +approve(spender, MAX)+ on the token contract, which this
260
+ # rejects: approve's first argument is a left-padded address, so it cannot be a
261
+ # rail0_id. Skipped only when there is no usable rail0_id to compare against.
262
+ def self.check_payment_id(data, rail0_id)
263
+ want = strip_hex(rail0_id.to_s)
264
+ return if want.length != WORD_HEX
265
+
266
+ raw = strip_hex(data)
267
+ if raw.length < SELECTOR_HEX + WORD_HEX
268
+ refuse("calldata is #{raw.length} hex chars, too short to carry a paymentId")
269
+ end
270
+ got = raw[SELECTOR_HEX, WORD_HEX]
271
+ refuse("the call acts on payment 0x#{got} but you asked for 0x#{want}") if got != want
272
+ end
273
+
274
+ def self.strip_hex(hex)
275
+ hex.to_s.strip.downcase.delete_prefix("0x")
276
+ end
277
+
278
+ private_class_method :check_call, :describe_selector, :check_payment_id, :strip_hex
279
+ end
280
+ 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.6.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.6.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
@@ -59,6 +59,7 @@ files:
59
59
  - lib/rail0/resources/webhooks.rb
60
60
  - lib/rail0/signing.rb
61
61
  - lib/rail0/stablecoins.rb
62
+ - lib/rail0/tx_verify.rb
62
63
  - lib/rail0/types.rb
63
64
  - lib/rail0/version.rb
64
65
  - lib/rail0/webhook_signature.rb