remitmd 0.1.9 → 0.2.1

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: 05f736527efb603117342dec70fc553420aa04d4dbc3ca8db5fccd8b31b7a9cb
4
- data.tar.gz: a7f143bc6302d96cc1ca492c7f8393cdd98b039e86747fee1e7bf36a919b83d0
3
+ metadata.gz: 75be0a5249f87323f7c007b13b7b89f9e2153557b355381000b7d3d007adcfc0
4
+ data.tar.gz: e0f9b80bc11f7eb9888f037cba3589548a3344bdba9f621994142f5b20a3b14d
5
5
  SHA512:
6
- metadata.gz: d998125805f98cf6ccbd389ec8df96655cb66a47e51b57d274c6090cdbb00a31c713f915282f166982d85dc2d3db3ce14a9c294f7ff0c00d049252590c661f10
7
- data.tar.gz: 0dad289442caafe80bcbf83b13f38f5e7d70c7adacd0ad8b6a12172e6c27a6aae872a9b97c29537fb6ee0783eb2d3261da4eb47889f677aba12a15c10bc05ab3
6
+ metadata.gz: 5198b515870956a99eccee4152e991d4fc32f6f03acd5a91a499b9c853b57da2c0b3cad4657381e1524553f10d021415b1d9202ce35848eb06331e5420758c73
7
+ data.tar.gz: 5bcaab9bc1d75389f524ad3ab36920a02bf9dbacf68792c6492f8bc04a6ebb2197725a73342191a26c2debf7dceddf31a3247968e82f470c228fe76203a51df0
data/README.md CHANGED
@@ -39,31 +39,35 @@ Or from environment variables:
39
39
 
40
40
  ```ruby
41
41
  wallet = Remitmd::RemitWallet.from_env
42
- # Requires: REMITMD_KEY (or REMIT_SIGNER_URL + REMIT_SIGNER_TOKEN)
42
+ # Auto-detects: CliSigner (remit CLI) > REMITMD_KEY
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)
48
+ ## CLI Signer (Recommended)
49
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.
50
+ The CLI signer delegates key management to the `remit` CLI binary, which holds your encrypted keystore at `~/.remit/keys/`. No private key in your environment -- just install the CLI and set a password.
51
51
 
52
52
  ```bash
53
- export REMIT_SIGNER_URL=http://127.0.0.1:7402
54
- export REMIT_SIGNER_TOKEN=rmit_sk_...
53
+ # Install the CLI
54
+ # macOS: brew install remit-md/tap/remit
55
+ # Windows: winget install remit-md.remit
56
+ # Linux: curl -fsSL https://remit.md/install.sh | sh
57
+
58
+ export REMIT_KEY_PASSWORD=your-keystore-password
55
59
  ```
56
60
 
57
61
  ```ruby
58
62
  # Explicit
59
- signer = Remitmd::HttpSigner.new(url: "http://127.0.0.1:7402", token: "rmit_sk_...")
63
+ signer = Remitmd::CliSigner.new
60
64
  wallet = Remitmd::RemitWallet.new(signer: signer)
61
65
 
62
66
  # Or auto-detect from env (recommended)
63
- wallet = Remitmd::RemitWallet.from_env # detects REMIT_SIGNER_URL automatically
67
+ wallet = Remitmd::RemitWallet.from_env # detects remit CLI automatically
64
68
  ```
65
69
 
66
- `RemitWallet.from_env` detects signer credentials automatically. Priority: `REMIT_SIGNER_URL` > `REMITMD_KEY`.
70
+ `RemitWallet.from_env` detects signing methods automatically. Priority: `CliSigner` (CLI + keystore + password) > `REMITMD_KEY`.
67
71
 
68
72
  ## Payment Models
69
73
 
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Remitmd
6
+ # Signer backed by the `remit sign` CLI command.
7
+ #
8
+ # No key material in this process -- signing happens in a subprocess.
9
+ # Address is cached at construction time via `remit address`.
10
+ #
11
+ # @example
12
+ # signer = Remitmd::CliSigner.new
13
+ # wallet = Remitmd::RemitWallet.new(signer: signer, chain: "base")
14
+ #
15
+ class CliSigner
16
+ include Signer
17
+
18
+ # Default timeout for CLI subprocess calls (seconds).
19
+ CLI_TIMEOUT = 10
20
+
21
+ # Create a CliSigner, fetching and caching the wallet address.
22
+ #
23
+ # @param cli_path [String] path or name of the remit CLI binary (default: "remit")
24
+ # @raise [RemitError] if the CLI fails or returns an invalid address
25
+ def initialize(cli_path: "remit")
26
+ @cli_path = cli_path
27
+ @address = fetch_address
28
+ end
29
+
30
+ # Sign a 32-byte digest (raw binary bytes).
31
+ # Pipes the hex-encoded digest to `remit sign --digest` on stdin.
32
+ # Returns a 0x-prefixed 65-byte hex signature.
33
+ #
34
+ # @param digest_bytes [String] 32-byte binary digest
35
+ # @return [String] 0x-prefixed 130-char hex signature (65 bytes)
36
+ # @raise [RemitError] on CLI failure or invalid output
37
+ def sign(digest_bytes)
38
+ hex = digest_bytes.unpack1("H*")
39
+ stdout, stderr, status = run_cli("sign", "--digest", stdin_data: hex)
40
+
41
+ unless status.success?
42
+ raise RemitError.new(
43
+ RemitError::SERVER_ERROR,
44
+ "CliSigner: signing failed: #{stderr.strip}"
45
+ )
46
+ end
47
+
48
+ sig = stdout.strip
49
+ unless sig.start_with?("0x") && sig.length == 132
50
+ raise RemitError.new(
51
+ RemitError::SERVER_ERROR,
52
+ "CliSigner: invalid signature from CLI: #{sig}"
53
+ )
54
+ end
55
+
56
+ sig
57
+ end
58
+
59
+ # The cached Ethereum address (0x-prefixed).
60
+ # @return [String]
61
+ attr_reader :address
62
+
63
+ # Never expose internals in inspect/to_s output.
64
+ def inspect
65
+ "#<Remitmd::CliSigner address=#{@address}>"
66
+ end
67
+
68
+ alias to_s inspect
69
+
70
+ # Check all three conditions for CliSigner activation.
71
+ #
72
+ # 1. CLI binary found on PATH (via `which` / `where`)
73
+ # 2. Keystore file exists at ~/.remit/keys/default.enc
74
+ # 3. REMIT_KEY_PASSWORD env var is set
75
+ #
76
+ # @param cli_path [String] path or name of the remit CLI binary
77
+ # @return [Boolean]
78
+ def self.available?(cli_path: "remit")
79
+ # 1. CLI binary on PATH
80
+ which_cmd = Gem.win_platform? ? "where" : "which"
81
+ _out, _err, st = Open3.capture3(which_cmd, cli_path)
82
+ return false unless st.success?
83
+
84
+ # 2. Keystore file exists
85
+ keystore = File.join(Dir.home, ".remit", "keys", "default.enc")
86
+ return false unless File.exist?(keystore)
87
+
88
+ # 3. REMIT_KEY_PASSWORD set
89
+ password = ENV["REMIT_KEY_PASSWORD"]
90
+ return false if password.nil? || password.empty?
91
+
92
+ true
93
+ end
94
+
95
+ private
96
+
97
+ # Fetch the wallet address from `remit address` during construction.
98
+ # @return [String] the 0x-prefixed Ethereum address
99
+ # @raise [RemitError] on any failure
100
+ def fetch_address
101
+ stdout, stderr, status = run_cli("address")
102
+
103
+ unless status.success?
104
+ raise RemitError.new(
105
+ RemitError::SERVER_ERROR,
106
+ "CliSigner: failed to get address: #{stderr.strip}"
107
+ )
108
+ end
109
+
110
+ addr = stdout.strip
111
+ unless addr.match?(/\A0x[0-9a-fA-F]{40}\z/)
112
+ raise RemitError.new(
113
+ RemitError::SERVER_ERROR,
114
+ "CliSigner: invalid address from CLI: #{addr}"
115
+ )
116
+ end
117
+
118
+ addr
119
+ end
120
+
121
+ # Run the remit CLI with given arguments.
122
+ # @param args [Array<String>] CLI arguments
123
+ # @param stdin_data [String, nil] data to pipe to stdin
124
+ # @return [Array<String, String, Process::Status>] stdout, stderr, status
125
+ # @raise [RemitError] on timeout or execution failure
126
+ def run_cli(*args, stdin_data: nil)
127
+ Open3.capture3(@cli_path, *args, stdin_data: stdin_data.to_s)
128
+ rescue Errno::ENOENT
129
+ raise RemitError.new(
130
+ RemitError::SERVER_ERROR,
131
+ "CliSigner: remit CLI not found at '#{@cli_path}'. " \
132
+ "Install: https://remit.md/install"
133
+ )
134
+ end
135
+ end
136
+ end
@@ -67,21 +67,16 @@ module Remitmd
67
67
  end
68
68
 
69
69
  # Build a RemitWallet from environment variables.
70
- # Reads: REMIT_SIGNER_URL + REMIT_SIGNER_TOKEN (preferred, uses HttpSigner),
71
- # or REMITMD_KEY (primary) / REMITMD_PRIVATE_KEY (deprecated fallback).
70
+ # Priority: CliSigner (if available) > REMITMD_KEY / REMITMD_PRIVATE_KEY.
72
71
  # Also reads: REMITMD_CHAIN, REMITMD_API_URL, REMITMD_ROUTER_ADDRESS.
73
72
  def self.from_env
74
73
  chain = ENV.fetch("REMITMD_CHAIN", "base")
75
74
  api_url = ENV["REMITMD_API_URL"]
76
75
  router_address = ENV["REMITMD_ROUTER_ADDRESS"]
77
76
 
78
- # Priority 1: HTTP signer server
79
- signer_url = ENV["REMIT_SIGNER_URL"]
80
- if signer_url
81
- signer_token = ENV["REMIT_SIGNER_TOKEN"]
82
- raise ArgumentError, "REMIT_SIGNER_TOKEN must be set when REMIT_SIGNER_URL is set" unless signer_token
83
-
84
- signer = HttpSigner.new(url: signer_url, token: signer_token)
77
+ # Priority 1: CLI signer (remit binary + keystore + password)
78
+ if CliSigner.available?
79
+ signer = CliSigner.new
85
80
  return new(signer: signer, chain: chain, api_url: api_url, router_address: router_address)
86
81
  end
87
82
 
@@ -90,7 +85,16 @@ module Remitmd
90
85
  if ENV["REMITMD_PRIVATE_KEY"] && !ENV["REMITMD_KEY"]
91
86
  warn "[remitmd] REMITMD_PRIVATE_KEY is deprecated, use REMITMD_KEY instead"
92
87
  end
93
- raise ArgumentError, "REMITMD_KEY not set (or set REMIT_SIGNER_URL + REMIT_SIGNER_TOKEN)" unless key
88
+
89
+ unless key
90
+ raise ArgumentError,
91
+ "No signing method available. Either:\n" \
92
+ " 1. Install the remit CLI and set REMIT_KEY_PASSWORD:\n" \
93
+ " macOS: brew install remit-md/tap/remit\n" \
94
+ " Windows: winget install remit-md.remit\n" \
95
+ " Linux: curl -fsSL https://remit.md/install.sh | sh\n" \
96
+ " 2. Set REMITMD_KEY to a raw private key (hex)"
97
+ end
94
98
 
95
99
  new(private_key: key, chain: chain, api_url: api_url, router_address: router_address)
96
100
  end
data/lib/remitmd.rb CHANGED
@@ -4,7 +4,7 @@ require_relative "remitmd/errors"
4
4
  require_relative "remitmd/models"
5
5
  require_relative "remitmd/keccak"
6
6
  require_relative "remitmd/signer"
7
- require_relative "remitmd/http_signer"
7
+ require_relative "remitmd/cli_signer"
8
8
  require_relative "remitmd/http"
9
9
  require_relative "remitmd/wallet"
10
10
  require_relative "remitmd/mock"
@@ -26,5 +26,5 @@ require_relative "remitmd/x402_paywall"
26
26
  # mock.was_paid?("0x0000000000000000000000000000000000000001", 1.00) # => true
27
27
  #
28
28
  module Remitmd
29
- VERSION = "0.1.9"
29
+ VERSION = "0.2.1"
30
30
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: remitmd
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.9
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - remit.md
@@ -51,9 +51,9 @@ files:
51
51
  - README.md
52
52
  - lib/remitmd.rb
53
53
  - lib/remitmd/a2a.rb
54
+ - lib/remitmd/cli_signer.rb
54
55
  - lib/remitmd/errors.rb
55
56
  - lib/remitmd/http.rb
56
- - lib/remitmd/http_signer.rb
57
57
  - lib/remitmd/keccak.rb
58
58
  - lib/remitmd/mock.rb
59
59
  - lib/remitmd/models.rb
@@ -1,200 +0,0 @@
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