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