remitmd 0.1.6 → 0.1.8
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 +22 -2
- data/lib/remitmd/a2a.rb +188 -2
- data/lib/remitmd/errors.rb +52 -17
- data/lib/remitmd/http.rb +8 -5
- data/lib/remitmd/http_signer.rb +200 -0
- data/lib/remitmd/keccak.rb +1 -1
- data/lib/remitmd/mock.rb +6 -4
- data/lib/remitmd/models.rb +83 -53
- data/lib/remitmd/wallet.rb +67 -38
- data/lib/remitmd/x402_client.rb +201 -0
- data/lib/remitmd/x402_paywall.rb +170 -0
- data/lib/remitmd.rb +5 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8c2b03f3cb2f1a05e9a33845448154e4d34fda42f65af31823be2f30fb14b6fe
|
|
4
|
+
data.tar.gz: 3d6dc094058152593779b5430899400d7b9d6543693ee3dd35dfd08b88f4c131
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: deca4edd8beb6ac2d798102a83838762d746f4508bf5e852bb1b43d74aed1fa24a7b328bc4880560fc5ab60f5bd2c937277123183c3c45cc430bf02407f5c5c8
|
|
7
|
+
data.tar.gz: c8b3bd0dc87584d00abe0b3acb37a52fa16f1f9af337780dd6aecc861f2cd542d2d684d636b28bef6b0227c13002c3c70388809d2e38a2874255bde0dd1c3932
|
data/README.md
CHANGED
|
@@ -24,7 +24,7 @@ gem install remitmd
|
|
|
24
24
|
```ruby
|
|
25
25
|
require "remitmd"
|
|
26
26
|
|
|
27
|
-
wallet = Remitmd::RemitWallet.new(private_key: ENV["
|
|
27
|
+
wallet = Remitmd::RemitWallet.new(private_key: ENV["REMITMD_KEY"])
|
|
28
28
|
|
|
29
29
|
# Direct payment
|
|
30
30
|
tx = wallet.pay("0xRecipient0000000000000000000000000000001", 1.50)
|
|
@@ -39,12 +39,32 @@ Or from environment variables:
|
|
|
39
39
|
|
|
40
40
|
```ruby
|
|
41
41
|
wallet = Remitmd::RemitWallet.from_env
|
|
42
|
-
# Requires:
|
|
42
|
+
# Requires: REMITMD_KEY (or REMIT_SIGNER_URL + REMIT_SIGNER_TOKEN)
|
|
43
43
|
# Optional: REMITMD_CHAIN (default: "base"), REMITMD_API_URL
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
Permits are auto-signed. Every payment method fetches the on-chain USDC nonce, signs an EIP-2612 permit, and includes it automatically.
|
|
47
47
|
|
|
48
|
+
## Local Signer (Recommended)
|
|
49
|
+
|
|
50
|
+
The local signer delegates key management to `remit signer`, a localhost HTTP server that holds your encrypted key. Your agent only needs a URL and token — no private key in the environment.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
export REMIT_SIGNER_URL=http://127.0.0.1:7402
|
|
54
|
+
export REMIT_SIGNER_TOKEN=rmit_sk_...
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
# Explicit
|
|
59
|
+
signer = Remitmd::HttpSigner.new(url: "http://127.0.0.1:7402", token: "rmit_sk_...")
|
|
60
|
+
wallet = Remitmd::RemitWallet.new(signer: signer)
|
|
61
|
+
|
|
62
|
+
# Or auto-detect from env (recommended)
|
|
63
|
+
wallet = Remitmd::RemitWallet.from_env # detects REMIT_SIGNER_URL automatically
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`RemitWallet.from_env` detects signer credentials automatically. Priority: `REMIT_SIGNER_URL` > `REMITMD_KEY`.
|
|
67
|
+
|
|
48
68
|
## Payment Models
|
|
49
69
|
|
|
50
70
|
### Direct Payment
|
data/lib/remitmd/a2a.rb
CHANGED
|
@@ -4,7 +4,7 @@ require "net/http"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "uri"
|
|
6
6
|
|
|
7
|
-
module
|
|
7
|
+
module Remitmd
|
|
8
8
|
# A2A capability extension declared in an agent card.
|
|
9
9
|
AgentExtension = Data.define(:uri, :description, :required)
|
|
10
10
|
|
|
@@ -37,7 +37,7 @@ module RemitMd
|
|
|
37
37
|
|
|
38
38
|
# Fetch and parse the A2A agent card from +base_url/.well-known/agent-card.json+.
|
|
39
39
|
#
|
|
40
|
-
# card =
|
|
40
|
+
# card = Remitmd::AgentCard.discover("https://remit.md")
|
|
41
41
|
# puts card.name # => "remit.md"
|
|
42
42
|
# puts card.url # => "https://remit.md/a2a"
|
|
43
43
|
#
|
|
@@ -55,4 +55,190 @@ module RemitMd
|
|
|
55
55
|
new(JSON.parse(response.body))
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
|
+
|
|
59
|
+
# ─── A2A task types ──────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
# Status of an A2A task.
|
|
62
|
+
class A2ATaskStatus
|
|
63
|
+
attr_reader :state, :message
|
|
64
|
+
|
|
65
|
+
def initialize(data)
|
|
66
|
+
@state = data["state"].to_s
|
|
67
|
+
@message = data["message"]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# An artifact part within an A2A artifact.
|
|
72
|
+
A2AArtifactPart = Data.define(:kind, :data)
|
|
73
|
+
|
|
74
|
+
# An artifact returned by an A2A task.
|
|
75
|
+
class A2AArtifact
|
|
76
|
+
attr_reader :name, :parts
|
|
77
|
+
|
|
78
|
+
def initialize(data)
|
|
79
|
+
@name = data["name"]
|
|
80
|
+
@parts = (data["parts"] || []).map do |p|
|
|
81
|
+
A2AArtifactPart.new(kind: p["kind"].to_s, data: p["data"] || {})
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# An A2A task returned by message/send, tasks/get, or tasks/cancel.
|
|
87
|
+
class A2ATask
|
|
88
|
+
attr_reader :id, :status, :artifacts
|
|
89
|
+
|
|
90
|
+
def initialize(data)
|
|
91
|
+
@id = data["id"].to_s
|
|
92
|
+
@status = A2ATaskStatus.new(data["status"] || {})
|
|
93
|
+
@artifacts = (data["artifacts"] || []).map { |a| A2AArtifact.new(a) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Extract txHash from task artifacts, if present.
|
|
97
|
+
# @return [String, nil]
|
|
98
|
+
def tx_hash
|
|
99
|
+
artifacts.each do |artifact|
|
|
100
|
+
artifact.parts.each do |part|
|
|
101
|
+
tx = part.data["txHash"] if part.data.is_a?(Hash)
|
|
102
|
+
return tx if tx.is_a?(String)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# ─── IntentMandate ──────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
# A mandate authorizing a payment intent.
|
|
112
|
+
class IntentMandate
|
|
113
|
+
attr_reader :mandate_id, :expires_at, :issuer, :max_amount, :currency
|
|
114
|
+
|
|
115
|
+
def initialize(mandate_id:, expires_at:, issuer:, max_amount:, currency: "USDC")
|
|
116
|
+
@mandate_id = mandate_id
|
|
117
|
+
@expires_at = expires_at
|
|
118
|
+
@issuer = issuer
|
|
119
|
+
@max_amount = max_amount
|
|
120
|
+
@currency = currency
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def to_h
|
|
124
|
+
{
|
|
125
|
+
mandateId: @mandate_id,
|
|
126
|
+
expiresAt: @expires_at,
|
|
127
|
+
issuer: @issuer,
|
|
128
|
+
allowance: {
|
|
129
|
+
maxAmount: @max_amount,
|
|
130
|
+
currency: @currency,
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# ─── A2A JSON-RPC client ────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
# A2A JSON-RPC client — send payments and manage tasks via the A2A protocol.
|
|
139
|
+
#
|
|
140
|
+
# @example
|
|
141
|
+
# card = Remitmd::AgentCard.discover("https://remit.md")
|
|
142
|
+
# signer = Remitmd::PrivateKeySigner.new(ENV["REMITMD_KEY"])
|
|
143
|
+
# client = Remitmd::A2AClient.from_card(card, signer)
|
|
144
|
+
# task = client.send(to: "0xRecipient...", amount: 10)
|
|
145
|
+
# puts task.status.state
|
|
146
|
+
#
|
|
147
|
+
class A2AClient
|
|
148
|
+
CHAIN_IDS = {
|
|
149
|
+
"base" => 8453,
|
|
150
|
+
"base-sepolia" => 84_532,
|
|
151
|
+
}.freeze
|
|
152
|
+
|
|
153
|
+
# @param endpoint [String] full A2A endpoint URL from the agent card
|
|
154
|
+
# @param signer [#sign, #address] a signer for EIP-712 authentication
|
|
155
|
+
# @param chain_id [Integer] chain ID
|
|
156
|
+
# @param verifying_contract [String] verifying contract address
|
|
157
|
+
def initialize(endpoint:, signer:, chain_id:, verifying_contract: "")
|
|
158
|
+
parsed = URI(endpoint)
|
|
159
|
+
@base_url = "#{parsed.scheme}://#{parsed.host}#{parsed.port == parsed.default_port ? "" : ":#{parsed.port}"}"
|
|
160
|
+
@path = parsed.path.empty? ? "/a2a" : parsed.path
|
|
161
|
+
@transport = HttpTransport.new(
|
|
162
|
+
base_url: @base_url,
|
|
163
|
+
signer: signer,
|
|
164
|
+
chain_id: chain_id,
|
|
165
|
+
router_address: verifying_contract
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Convenience constructor from an AgentCard and a signer.
|
|
170
|
+
# @param card [AgentCard]
|
|
171
|
+
# @param signer [#sign, #address]
|
|
172
|
+
# @param chain [String] chain name (default: "base")
|
|
173
|
+
# @param verifying_contract [String]
|
|
174
|
+
# @return [A2AClient]
|
|
175
|
+
def self.from_card(card, signer, chain: "base", verifying_contract: "")
|
|
176
|
+
chain_id = CHAIN_IDS[chain] || CHAIN_IDS["base"]
|
|
177
|
+
new(
|
|
178
|
+
endpoint: card.url,
|
|
179
|
+
signer: signer,
|
|
180
|
+
chain_id: chain_id,
|
|
181
|
+
verifying_contract: verifying_contract
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Send a direct USDC payment via message/send.
|
|
186
|
+
# @param to [String] recipient 0x address
|
|
187
|
+
# @param amount [Numeric] amount in USDC
|
|
188
|
+
# @param memo [String] optional memo
|
|
189
|
+
# @param mandate [IntentMandate, nil] optional intent mandate
|
|
190
|
+
# @return [A2ATask]
|
|
191
|
+
def send(to:, amount:, memo: "", mandate: nil)
|
|
192
|
+
nonce = SecureRandom.hex(16)
|
|
193
|
+
message_id = SecureRandom.hex(16)
|
|
194
|
+
|
|
195
|
+
message = {
|
|
196
|
+
messageId: message_id,
|
|
197
|
+
role: "user",
|
|
198
|
+
parts: [
|
|
199
|
+
{
|
|
200
|
+
kind: "data",
|
|
201
|
+
data: {
|
|
202
|
+
model: "direct",
|
|
203
|
+
to: to,
|
|
204
|
+
amount: format("%.2f", amount),
|
|
205
|
+
memo: memo,
|
|
206
|
+
nonce: nonce,
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
message[:metadata] = { mandate: mandate.to_h } if mandate
|
|
213
|
+
|
|
214
|
+
rpc("message/send", { message: message }, message_id)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Fetch the current state of an A2A task by ID.
|
|
218
|
+
# @param task_id [String]
|
|
219
|
+
# @return [A2ATask]
|
|
220
|
+
def get_task(task_id)
|
|
221
|
+
rpc("tasks/get", { id: task_id }, task_id[0, 16])
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Cancel an in-progress A2A task.
|
|
225
|
+
# @param task_id [String]
|
|
226
|
+
# @return [A2ATask]
|
|
227
|
+
def cancel_task(task_id)
|
|
228
|
+
rpc("tasks/cancel", { id: task_id }, task_id[0, 16])
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
def rpc(method, params, call_id)
|
|
234
|
+
body = { jsonrpc: "2.0", id: call_id, method: method, params: params }
|
|
235
|
+
data = @transport.post(@path, body)
|
|
236
|
+
if data.is_a?(Hash) && data["error"]
|
|
237
|
+
err_msg = data["error"]["message"] || JSON.generate(data["error"])
|
|
238
|
+
raise RemitError.new("SERVER_ERROR", "A2A error: #{err_msg}")
|
|
239
|
+
end
|
|
240
|
+
result = data.is_a?(Hash) ? (data["result"] || data) : data
|
|
241
|
+
A2ATask.new(result)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
58
244
|
end
|
data/lib/remitmd/errors.rb
CHANGED
|
@@ -15,29 +15,64 @@ module Remitmd
|
|
|
15
15
|
# puts e.doc_url # => "https://remit.md/docs/api-reference/error-codes#invalid_address"
|
|
16
16
|
# end
|
|
17
17
|
class RemitError < StandardError
|
|
18
|
-
# Error code constants
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
# Error code constants — matches TS SDK (28 codes)
|
|
19
|
+
# Auth errors
|
|
20
|
+
INVALID_SIGNATURE = "INVALID_SIGNATURE"
|
|
21
|
+
NONCE_REUSED = "NONCE_REUSED"
|
|
22
|
+
TIMESTAMP_EXPIRED = "TIMESTAMP_EXPIRED"
|
|
23
|
+
UNAUTHORIZED = "UNAUTHORIZED"
|
|
24
|
+
|
|
25
|
+
# Balance / funds
|
|
26
|
+
INSUFFICIENT_BALANCE = "INSUFFICIENT_BALANCE"
|
|
27
|
+
BELOW_MINIMUM = "BELOW_MINIMUM"
|
|
28
|
+
|
|
29
|
+
# Escrow errors
|
|
22
30
|
ESCROW_NOT_FOUND = "ESCROW_NOT_FOUND"
|
|
31
|
+
ESCROW_ALREADY_FUNDED = "ESCROW_ALREADY_FUNDED"
|
|
32
|
+
ESCROW_EXPIRED = "ESCROW_EXPIRED"
|
|
33
|
+
|
|
34
|
+
# Invoice errors
|
|
35
|
+
INVALID_INVOICE = "INVALID_INVOICE"
|
|
36
|
+
DUPLICATE_INVOICE = "DUPLICATE_INVOICE"
|
|
37
|
+
SELF_PAYMENT = "SELF_PAYMENT"
|
|
38
|
+
INVALID_PAYMENT_TYPE = "INVALID_PAYMENT_TYPE"
|
|
39
|
+
|
|
40
|
+
# Tab errors
|
|
41
|
+
TAB_DEPLETED = "TAB_DEPLETED"
|
|
42
|
+
TAB_EXPIRED = "TAB_EXPIRED"
|
|
23
43
|
TAB_NOT_FOUND = "TAB_NOT_FOUND"
|
|
44
|
+
|
|
45
|
+
# Stream errors
|
|
24
46
|
STREAM_NOT_FOUND = "STREAM_NOT_FOUND"
|
|
47
|
+
RATE_EXCEEDS_CAP = "RATE_EXCEEDS_CAP"
|
|
48
|
+
|
|
49
|
+
# Bounty errors
|
|
50
|
+
BOUNTY_EXPIRED = "BOUNTY_EXPIRED"
|
|
51
|
+
BOUNTY_CLAIMED = "BOUNTY_CLAIMED"
|
|
52
|
+
BOUNTY_MAX_ATTEMPTS = "BOUNTY_MAX_ATTEMPTS"
|
|
25
53
|
BOUNTY_NOT_FOUND = "BOUNTY_NOT_FOUND"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
54
|
+
|
|
55
|
+
# Chain errors
|
|
56
|
+
CHAIN_MISMATCH = "CHAIN_MISMATCH"
|
|
57
|
+
CHAIN_UNSUPPORTED = "CHAIN_UNSUPPORTED"
|
|
58
|
+
|
|
59
|
+
# Rate limiting
|
|
60
|
+
RATE_LIMITED = "RATE_LIMITED"
|
|
61
|
+
|
|
62
|
+
# Cancellation errors
|
|
63
|
+
CANCEL_BLOCKED_CLAIM_START = "CANCEL_BLOCKED_CLAIM_START"
|
|
64
|
+
CANCEL_BLOCKED_EVIDENCE = "CANCEL_BLOCKED_EVIDENCE"
|
|
65
|
+
|
|
66
|
+
# Protocol errors
|
|
67
|
+
VERSION_MISMATCH = "VERSION_MISMATCH"
|
|
29
68
|
NETWORK_ERROR = "NETWORK_ERROR"
|
|
69
|
+
|
|
70
|
+
# Legacy aliases (kept for backward compat within the SDK)
|
|
71
|
+
INVALID_ADDRESS = "INVALID_ADDRESS"
|
|
72
|
+
INVALID_AMOUNT = "INVALID_AMOUNT"
|
|
73
|
+
INSUFFICIENT_FUNDS = INSUFFICIENT_BALANCE
|
|
30
74
|
SERVER_ERROR = "SERVER_ERROR"
|
|
31
|
-
|
|
32
|
-
SIGNATURE_INVALID = "SIGNATURE_INVALID"
|
|
33
|
-
ESCROW_ALREADY_COMPLETED = "ESCROW_ALREADY_COMPLETED"
|
|
34
|
-
ESCROW_EXPIRED = "ESCROW_EXPIRED"
|
|
35
|
-
TAB_LIMIT_EXCEEDED = "TAB_LIMIT_EXCEEDED"
|
|
36
|
-
BOUNTY_ALREADY_AWARDED = "BOUNTY_ALREADY_AWARDED"
|
|
37
|
-
STREAM_NOT_ACTIVE = "STREAM_NOT_ACTIVE"
|
|
38
|
-
DEPOSIT_ALREADY_RESOLVED = "DEPOSIT_ALREADY_RESOLVED"
|
|
39
|
-
USDC_TRANSFER_FAILED = "USDC_TRANSFER_FAILED"
|
|
40
|
-
CHAIN_UNAVAILABLE = "CHAIN_UNAVAILABLE"
|
|
75
|
+
DEPOSIT_NOT_FOUND = "DEPOSIT_NOT_FOUND"
|
|
41
76
|
|
|
42
77
|
attr_reader :code, :doc_url, :context
|
|
43
78
|
|
data/lib/remitmd/http.rb
CHANGED
|
@@ -43,7 +43,7 @@ module Remitmd
|
|
|
43
43
|
def request(method, path, body)
|
|
44
44
|
attempt = 0
|
|
45
45
|
# Generate idempotency key once per request (stable across retries).
|
|
46
|
-
idempotency_key =
|
|
46
|
+
idempotency_key = %i[post put patch].include?(method) ? SecureRandom.uuid : nil
|
|
47
47
|
begin
|
|
48
48
|
attempt += 1
|
|
49
49
|
req = build_request(method, path, body, idempotency_key)
|
|
@@ -74,9 +74,10 @@ module Remitmd
|
|
|
74
74
|
nonce_hex = "0x#{nonce_bytes.unpack1("H*")}"
|
|
75
75
|
timestamp = Time.now.to_i
|
|
76
76
|
|
|
77
|
-
#
|
|
77
|
+
# Strip query string before signing — only the path is included in EIP-712.
|
|
78
|
+
sign_path = full_path.split("?").first
|
|
78
79
|
http_method = method.to_s.upcase
|
|
79
|
-
digest = eip712_hash(http_method,
|
|
80
|
+
digest = eip712_hash(http_method, sign_path, timestamp, nonce_bytes)
|
|
80
81
|
signature = @signer.sign(digest)
|
|
81
82
|
|
|
82
83
|
req["Content-Type"] = "application/json"
|
|
@@ -155,8 +156,10 @@ module Remitmd
|
|
|
155
156
|
when 200..299
|
|
156
157
|
parsed
|
|
157
158
|
when 400
|
|
158
|
-
|
|
159
|
-
|
|
159
|
+
# Support nested error format: { "error": { "code": "...", "message": "..." } }
|
|
160
|
+
err = parsed.is_a?(Hash) && parsed["error"].is_a?(Hash) ? parsed["error"] : parsed
|
|
161
|
+
code = err["code"] || RemitError::SERVER_ERROR
|
|
162
|
+
raise RemitError.new(code, err["message"] || "Bad request", context: parsed)
|
|
160
163
|
when 401
|
|
161
164
|
raise RemitError.new(RemitError::UNAUTHORIZED,
|
|
162
165
|
"Authentication failed — check your private key and chain ID")
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Remitmd
|
|
8
|
+
# Signer backed by a local HTTP signing server.
|
|
9
|
+
#
|
|
10
|
+
# Delegates digest signing to an HTTP server (typically
|
|
11
|
+
# `http://127.0.0.1:7402`). The signer server holds the encrypted key;
|
|
12
|
+
# this adapter only needs a bearer token and URL.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# signer = Remitmd::HttpSigner.new(url: "http://127.0.0.1:7402", token: "rmit_sk_...")
|
|
16
|
+
# wallet = Remitmd::RemitWallet.new(signer: signer, chain: "base")
|
|
17
|
+
#
|
|
18
|
+
class HttpSigner
|
|
19
|
+
include Signer
|
|
20
|
+
|
|
21
|
+
# Create an HttpSigner, fetching and caching the wallet address.
|
|
22
|
+
#
|
|
23
|
+
# @param url [String] signer server URL (e.g. "http://127.0.0.1:7402")
|
|
24
|
+
# @param token [String] bearer token for authentication
|
|
25
|
+
# @raise [RemitError] if the server is unreachable, returns an error, or returns no address
|
|
26
|
+
def initialize(url:, token:)
|
|
27
|
+
@url = url.chomp("/")
|
|
28
|
+
@token = token
|
|
29
|
+
@address = fetch_address
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Sign a 32-byte digest (raw binary bytes).
|
|
33
|
+
# Posts to /sign/digest with the hex-encoded digest.
|
|
34
|
+
# Returns a 0x-prefixed 65-byte hex signature.
|
|
35
|
+
#
|
|
36
|
+
# @param digest_bytes [String] 32-byte binary digest
|
|
37
|
+
# @return [String] 0x-prefixed 65-byte hex signature
|
|
38
|
+
# @raise [RemitError] on network, auth, policy, or server errors
|
|
39
|
+
def sign(digest_bytes)
|
|
40
|
+
hex = "0x#{digest_bytes.unpack1("H*")}"
|
|
41
|
+
uri = URI("#{@url}/sign/digest")
|
|
42
|
+
http = build_http(uri)
|
|
43
|
+
|
|
44
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
45
|
+
req["Content-Type"] = "application/json"
|
|
46
|
+
req["Authorization"] = "Bearer #{@token}"
|
|
47
|
+
req.body = { digest: hex }.to_json
|
|
48
|
+
|
|
49
|
+
resp = begin
|
|
50
|
+
http.request(req)
|
|
51
|
+
rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError => e
|
|
52
|
+
raise RemitError.new(
|
|
53
|
+
RemitError::NETWORK_ERROR,
|
|
54
|
+
"HttpSigner: cannot reach signer server at #{@url}: #{e.message}"
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
handle_sign_response(resp)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The cached Ethereum address (0x-prefixed).
|
|
62
|
+
# @return [String]
|
|
63
|
+
attr_reader :address
|
|
64
|
+
|
|
65
|
+
# Never expose the bearer token in inspect/to_s output.
|
|
66
|
+
def inspect
|
|
67
|
+
"#<Remitmd::HttpSigner address=#{@address}>"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
alias to_s inspect
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Fetch the wallet address from GET /address during construction.
|
|
75
|
+
# @return [String] the 0x-prefixed Ethereum address
|
|
76
|
+
# @raise [RemitError] on any failure
|
|
77
|
+
def fetch_address
|
|
78
|
+
uri = URI("#{@url}/address")
|
|
79
|
+
http = build_http(uri)
|
|
80
|
+
|
|
81
|
+
req = Net::HTTP::Get.new(uri.path)
|
|
82
|
+
req["Authorization"] = "Bearer #{@token}"
|
|
83
|
+
|
|
84
|
+
resp = begin
|
|
85
|
+
http.request(req)
|
|
86
|
+
rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError => e
|
|
87
|
+
raise RemitError.new(
|
|
88
|
+
RemitError::NETWORK_ERROR,
|
|
89
|
+
"HttpSigner: cannot reach signer server at #{@url}: #{e.message}"
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
status = resp.code.to_i
|
|
94
|
+
|
|
95
|
+
if status == 401
|
|
96
|
+
raise RemitError.new(
|
|
97
|
+
RemitError::UNAUTHORIZED,
|
|
98
|
+
"HttpSigner: unauthorized -- check your REMIT_SIGNER_TOKEN"
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
unless (200..299).cover?(status)
|
|
103
|
+
raise RemitError.new(
|
|
104
|
+
RemitError::SERVER_ERROR,
|
|
105
|
+
"HttpSigner: GET /address failed (#{status})"
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
body = begin
|
|
110
|
+
JSON.parse(resp.body.to_s)
|
|
111
|
+
rescue JSON::ParserError
|
|
112
|
+
raise RemitError.new(
|
|
113
|
+
RemitError::SERVER_ERROR,
|
|
114
|
+
"HttpSigner: GET /address returned malformed JSON"
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
addr = body["address"]
|
|
119
|
+
if addr.nil? || addr.to_s.empty?
|
|
120
|
+
raise RemitError.new(
|
|
121
|
+
RemitError::SERVER_ERROR,
|
|
122
|
+
"HttpSigner: GET /address returned no address"
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
addr.to_s
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Handle the response from POST /sign/digest.
|
|
130
|
+
# @param resp [Net::HTTPResponse]
|
|
131
|
+
# @return [String] the 0x-prefixed hex signature
|
|
132
|
+
# @raise [RemitError] on any error
|
|
133
|
+
def handle_sign_response(resp)
|
|
134
|
+
status = resp.code.to_i
|
|
135
|
+
|
|
136
|
+
if status == 401
|
|
137
|
+
raise RemitError.new(
|
|
138
|
+
RemitError::UNAUTHORIZED,
|
|
139
|
+
"HttpSigner: unauthorized -- check your REMIT_SIGNER_TOKEN"
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
if status == 403
|
|
144
|
+
reason = begin
|
|
145
|
+
data = JSON.parse(resp.body.to_s)
|
|
146
|
+
data["reason"] || "unknown"
|
|
147
|
+
rescue JSON::ParserError
|
|
148
|
+
"unknown"
|
|
149
|
+
end
|
|
150
|
+
raise RemitError.new(
|
|
151
|
+
RemitError::UNAUTHORIZED,
|
|
152
|
+
"HttpSigner: policy denied -- #{reason}"
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
unless (200..299).cover?(status)
|
|
157
|
+
detail = begin
|
|
158
|
+
data = JSON.parse(resp.body.to_s)
|
|
159
|
+
data["reason"] || data["error"] || "server error"
|
|
160
|
+
rescue JSON::ParserError
|
|
161
|
+
"server error"
|
|
162
|
+
end
|
|
163
|
+
raise RemitError.new(
|
|
164
|
+
RemitError::SERVER_ERROR,
|
|
165
|
+
"HttpSigner: sign failed (#{status}): #{detail}"
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
body = begin
|
|
170
|
+
JSON.parse(resp.body.to_s)
|
|
171
|
+
rescue JSON::ParserError
|
|
172
|
+
raise RemitError.new(
|
|
173
|
+
RemitError::SERVER_ERROR,
|
|
174
|
+
"HttpSigner: POST /sign/digest returned malformed JSON"
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
sig = body["signature"]
|
|
179
|
+
if sig.nil? || sig.to_s.empty?
|
|
180
|
+
raise RemitError.new(
|
|
181
|
+
RemitError::SERVER_ERROR,
|
|
182
|
+
"HttpSigner: server returned no signature"
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
sig.to_s
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Build a Net::HTTP client for the given URI.
|
|
190
|
+
# @param uri [URI] the target URI
|
|
191
|
+
# @return [Net::HTTP]
|
|
192
|
+
def build_http(uri)
|
|
193
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
194
|
+
http.use_ssl = uri.scheme == "https"
|
|
195
|
+
http.open_timeout = 5
|
|
196
|
+
http.read_timeout = 10
|
|
197
|
+
http
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
data/lib/remitmd/keccak.rb
CHANGED
|
@@ -77,7 +77,7 @@ module Remitmd
|
|
|
77
77
|
RC.each { |rc| keccak_round!(state, rc) }
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
-
def keccak_round!(state, rc)
|
|
80
|
+
def keccak_round!(state, rc)
|
|
81
81
|
# Theta
|
|
82
82
|
c = Array.new(5) do |x|
|
|
83
83
|
state[x] ^ state[x + 5] ^ state[x + 10] ^ state[x + 15] ^ state[x + 20]
|
data/lib/remitmd/mock.rb
CHANGED
|
@@ -180,6 +180,7 @@ module Remitmd
|
|
|
180
180
|
invoice_id = fetch!(b, :invoice_id)
|
|
181
181
|
inv = @state[:pending_invoices].delete(invoice_id)
|
|
182
182
|
raise not_found(RemitError::ESCROW_NOT_FOUND, invoice_id) unless inv
|
|
183
|
+
|
|
183
184
|
payee = (inv[:to_agent] || inv["to_agent"]).to_s
|
|
184
185
|
amount = decimal!(inv, :amount)
|
|
185
186
|
memo = (inv[:task] || inv["task"]).to_s
|
|
@@ -310,7 +311,7 @@ module Remitmd
|
|
|
310
311
|
# Bounty submit
|
|
311
312
|
in ["POST", path] if path.end_with?("/submit") && path.include?("/bounties/")
|
|
312
313
|
id = extract_id(path, "/bounties/", "/submit")
|
|
313
|
-
|
|
314
|
+
@state[:bounties].fetch(id) { raise not_found(RemitError::BOUNTY_NOT_FOUND, id) }
|
|
314
315
|
{
|
|
315
316
|
"id" => 1,
|
|
316
317
|
"bounty_id" => id,
|
|
@@ -322,9 +323,9 @@ module Remitmd
|
|
|
322
323
|
|
|
323
324
|
# Bounty award
|
|
324
325
|
in ["POST", path] if path.end_with?("/award") && path.include?("/bounties/")
|
|
325
|
-
id
|
|
326
|
-
|
|
327
|
-
bnt
|
|
326
|
+
id = extract_id(path, "/bounties/", "/award")
|
|
327
|
+
_submission_id = (b[:submission_id] || b["submission_id"]).to_i
|
|
328
|
+
bnt = @state[:bounties].fetch(id) { raise not_found(RemitError::BOUNTY_NOT_FOUND, id) }
|
|
328
329
|
new_bnt = update_bounty(bnt, status: BountyStatus::AWARDED)
|
|
329
330
|
@state[:bounties][id] = new_bnt
|
|
330
331
|
bounty_hash(new_bnt)
|
|
@@ -620,6 +621,7 @@ module Remitmd
|
|
|
620
621
|
def check_balance!(amount)
|
|
621
622
|
bal = @state[:balance]
|
|
622
623
|
return if bal >= amount
|
|
624
|
+
|
|
623
625
|
raise RemitError.new(
|
|
624
626
|
RemitError::INSUFFICIENT_FUNDS,
|
|
625
627
|
"Insufficient balance: have #{bal.to_s("F")} USDC, need #{amount.to_s("F")} USDC",
|