honeymaker 0.9.0 → 0.9.3

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: 12080b0cbcae6a52a2680efde8f4131b30f4ebcf70647c1c297ac88217c459ba
4
- data.tar.gz: c475a4e5da6e8f7ac2e0b5f3eaa84e12192eec504876871028935a6b3be05cae
3
+ metadata.gz: 8751e8712e47b27bf82073fa2b7de4ec7d7404e9b6195a6cbcd06a2a48749acd
4
+ data.tar.gz: 414e8fd8e266d7d8cd7acc3abd4b39f18d450fb5ba08ebf0b9121c599fc7ff9f
5
5
  SHA512:
6
- metadata.gz: c6c9bffcfa55f7f096a4128c2b09119e06d11e5be58bc04dde162a693600595e7662334034d38a049b7faaa2f9535976668b22890564bf6ddcf82f8f1c1ade2e
7
- data.tar.gz: eedbdfab0c9fa12e8b4ae86362eafcd90b4d8319caace49bdb642405ca99a90836af69a6c8a50d9a13e555f6f1be4008175fb7e019927d8198060d7835a3754c
6
+ metadata.gz: ccf214595dccd90388f8a77dffca7cb0618047c025531c219c1030d2af821d96b682f67f991ff701de013f847cea045b5c7a9b4f5f1a6a96f19490549c18a6ef
7
+ data.tar.gz: e724a084bfc8bd6bca47bd6bced65a12eb75818e58b05f31022d549d246bb8fa1920763bbc27179b1d0339938b899309ee8b8be0f8718a96163834b9f8107a8d
@@ -7,6 +7,11 @@ module Honeymaker
7
7
  ACCESS_WINDOW = "10000"
8
8
  RATE_LIMITS = { default: 100, orders: 100 }.freeze
9
9
 
10
+ # Bitvavo requires a mandatory operatorId on every order create/cancel
11
+ # (errorCode 203). It's a positive int64 the caller sets themselves to
12
+ # attribute an order to a trader/bot for audit; the value is cosmetic.
13
+ DEFAULT_OPERATOR_ID = 1
14
+
10
15
  def get_assets
11
16
  get_public("/v2/assets")
12
17
  end
@@ -51,11 +56,12 @@ module Honeymaker
51
56
  end
52
57
 
53
58
  def place_order(market:, side:, order_type:, amount: nil, amount_quote: nil, price: nil,
54
- time_in_force: nil, client_order_id: nil)
59
+ time_in_force: nil, client_order_id: nil, operator_id: nil)
55
60
  result = post_signed("/v2/order", {
56
61
  market: market, side: side, orderType: order_type,
57
62
  amount: amount, amountQuote: amount_quote, price: price,
58
- timeInForce: time_in_force, clientOrderId: client_order_id
63
+ timeInForce: time_in_force, clientOrderId: client_order_id,
64
+ operatorId: operator_id || DEFAULT_OPERATOR_ID
59
65
  })
60
66
  return result if result.failure?
61
67
 
@@ -71,8 +77,10 @@ module Honeymaker
71
77
  Result::Success.new(normalize_order("#{raw['market']}-#{raw['orderId']}", raw))
72
78
  end
73
79
 
74
- def cancel_order(market:, order_id:)
75
- delete_signed("/v2/order", { market: market, orderId: order_id })
80
+ def cancel_order(market:, order_id:, operator_id: nil)
81
+ delete_signed("/v2/order", {
82
+ market: market, orderId: order_id, operatorId: operator_id || DEFAULT_OPERATOR_ID
83
+ })
76
84
  end
77
85
 
78
86
  def get_trades(market:, limit: nil, start_time: nil, end_time: nil, trade_id_from: nil, trade_id_to: nil)
@@ -237,8 +237,25 @@ module Honeymaker
237
237
  end
238
238
  end
239
239
 
240
+ # Kraken rejects any request whose nonce is <= the last nonce it saw for the
241
+ # same API key. We protect against in-process collisions (microsecond bursts,
242
+ # backward clock corrections) by tracking the last issued nonce per API key
243
+ # under a mutex. Cross-process reuse of the same API key (other workers,
244
+ # other containers, other tools) is not protected here — affected users
245
+ # should increase the Nonce Window on their Kraken API key.
246
+ @@nonce_mutex = Mutex.new
247
+ @@last_nonces = {}
248
+
249
+ def self.reset_nonce_state!
250
+ @@nonce_mutex.synchronize { @@last_nonces.clear }
251
+ end
252
+
240
253
  def nonce
241
- (Time.now.utc.to_f * 1_000_000).to_i
254
+ key = @api_key ? Digest::SHA256.hexdigest(@api_key) : :__no_api_key__
255
+ @@nonce_mutex.synchronize do
256
+ candidate = (Time.now.utc.to_f * 1_000_000).to_i
257
+ @@last_nonces[key] = [candidate, (@@last_nonces[key] || 0) + 1].max
258
+ end
242
259
  end
243
260
 
244
261
  def private_headers(path, body)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Honeymaker
4
- VERSION = "0.9.0"
4
+ VERSION = "0.9.3"
5
5
  end
@@ -47,6 +47,36 @@ class Honeymaker::Clients::BitvavoTest < Minitest::Test
47
47
  assert result.success?
48
48
  end
49
49
 
50
+ # Bitvavo requires a mandatory integer operatorId on every order create/cancel
51
+ # (errorCode 203). These tests yield the real Faraday request block so we assert
52
+ # what the client actually sends, not just the stubbed response.
53
+
54
+ def test_place_order_sends_default_operator_id
55
+ req = capture_request(:post, { "orderId" => "123" })
56
+ @client.place_order(market: "BTC-EUR", side: "buy", order_type: "market", amount_quote: "100")
57
+ assert_equal @client.class::DEFAULT_OPERATOR_ID, req.body[:operatorId]
58
+ assert_kind_of Integer, req.body[:operatorId]
59
+ end
60
+
61
+ def test_place_order_operator_id_override
62
+ req = capture_request(:post, { "orderId" => "123" })
63
+ @client.place_order(market: "BTC-EUR", side: "buy", order_type: "market", amount_quote: "100", operator_id: 42)
64
+ assert_equal 42, req.body[:operatorId]
65
+ end
66
+
67
+ def test_cancel_order_sends_default_operator_id
68
+ req = capture_request(:delete, { "orderId" => "123" })
69
+ @client.cancel_order(market: "BTC-EUR", order_id: "123")
70
+ assert_equal @client.class::DEFAULT_OPERATOR_ID, req.params[:operatorId]
71
+ assert_kind_of Integer, req.params[:operatorId]
72
+ end
73
+
74
+ def test_cancel_order_operator_id_override
75
+ req = capture_request(:delete, { "orderId" => "123" })
76
+ @client.cancel_order(market: "BTC-EUR", order_id: "123", operator_id: 42)
77
+ assert_equal 42, req.params[:operatorId]
78
+ end
79
+
50
80
  def test_withdraw
51
81
  stub_connection(:post, { "success" => true })
52
82
  result = @client.withdraw(symbol: "BTC", amount: "0.1", address: "addr")
@@ -102,4 +132,25 @@ class Honeymaker::Clients::BitvavoTest < Minitest::Test
102
132
  connection.stubs(method).returns(response)
103
133
  @client.instance_variable_set(:@connection, connection)
104
134
  end
135
+
136
+ # Records what the client sets on the Faraday request. Unlike stub_connection,
137
+ # this yields the request block (connection.post { |req| ... }) so the real
138
+ # body/params-building code in post_signed/delete_signed actually executes.
139
+ class RequestRecorder
140
+ attr_accessor :headers, :body, :params
141
+ attr_reader :requested_url
142
+
143
+ def url(path)
144
+ @requested_url = path
145
+ end
146
+ end
147
+
148
+ def capture_request(method, body)
149
+ response = stub(body: body)
150
+ recorder = RequestRecorder.new
151
+ connection = stub
152
+ connection.stubs(method).yields(recorder).returns(response)
153
+ @client.instance_variable_set(:@connection, connection)
154
+ recorder
155
+ end
105
156
  end
@@ -4,6 +4,7 @@ require "test_helper"
4
4
 
5
5
  class Honeymaker::Clients::KrakenTest < Minitest::Test
6
6
  def setup
7
+ Honeymaker::Clients::Kraken.reset_nonce_state! if Honeymaker::Clients::Kraken.respond_to?(:reset_nonce_state!)
7
8
  @client = Honeymaker::Clients::Kraken.new(
8
9
  api_key: "test_key",
9
10
  api_secret: Base64.strict_encode64("test_secret_key_1234567890123456")
@@ -74,8 +75,65 @@ class Honeymaker::Clients::KrakenTest < Minitest::Test
74
75
  refute headers.key?(:"API-Key")
75
76
  end
76
77
 
78
+ def test_reset_nonce_state_is_available_for_test_isolation
79
+ assert_respond_to Honeymaker::Clients::Kraken, :reset_nonce_state!
80
+ end
81
+
82
+ def test_nonce_is_strictly_increasing_in_a_tight_loop
83
+ nonces = Array.new(10_000) { @client.send(:nonce) }
84
+
85
+ assert_strictly_increasing nonces
86
+ end
87
+
88
+ def test_nonce_is_strictly_increasing_when_clock_is_frozen
89
+ frozen_time = Time.utc(2026, 1, 1, 12, 0, 0)
90
+ Time.stubs(:now).returns(frozen_time)
91
+
92
+ nonces = Array.new(3) { @client.send(:nonce) }
93
+
94
+ assert_strictly_increasing nonces
95
+ end
96
+
97
+ def test_nonce_is_strictly_increasing_across_clients_with_the_same_api_key
98
+ frozen_time = Time.utc(2026, 1, 1, 12, 0, 0)
99
+ Time.stubs(:now).returns(frozen_time)
100
+ other_client = Honeymaker::Clients::Kraken.new(
101
+ api_key: "test_key",
102
+ api_secret: Base64.strict_encode64("test_secret_key_1234567890123456")
103
+ )
104
+
105
+ nonces = [
106
+ @client.send(:nonce),
107
+ other_client.send(:nonce),
108
+ @client.send(:nonce),
109
+ other_client.send(:nonce)
110
+ ]
111
+
112
+ assert_strictly_increasing nonces
113
+ end
114
+
115
+ def test_nonce_sequences_are_independent_for_different_api_keys
116
+ frozen_time = Time.utc(2026, 1, 1, 12, 0, 0)
117
+ Time.stubs(:now).returns(frozen_time)
118
+ first_key_client = Honeymaker::Clients::Kraken.new(api_key: "first_key", api_secret: @client.api_secret)
119
+ second_key_client = Honeymaker::Clients::Kraken.new(api_key: "second_key", api_secret: @client.api_secret)
120
+
121
+ first_key_first_nonce = first_key_client.send(:nonce)
122
+ first_key_second_nonce = first_key_client.send(:nonce)
123
+ second_key_first_nonce = second_key_client.send(:nonce)
124
+
125
+ assert_operator first_key_second_nonce, :>, first_key_first_nonce
126
+ assert_equal first_key_first_nonce, second_key_first_nonce
127
+ end
128
+
77
129
  private
78
130
 
131
+ def assert_strictly_increasing(values)
132
+ values.each_cons(2) do |previous, current|
133
+ assert_operator current, :>, previous
134
+ end
135
+ end
136
+
79
137
  def stub_connection(method, body)
80
138
  response = stub(body: body)
81
139
  connection = stub
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: honeymaker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.9.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Deltabadger