hpyhacking/peatio · error · APIv2::TonceUsedError
2006
2006
Error message
The tonce #{tonce} has already been used by access key #{access_key}. What it means
Error code 2006 (TonceUsedError) is raised by APIv2::Auth::Authenticator#check_tonce! when Utils.cache (a Rack::Attack cache wrapper, so a shared store when Redis-backed) already holds the key "api_v2:tonce:<access_key>:<tonce>". Every tonce that passes this check is written to the cache for 61 seconds, so the same access key cannot reuse a tonce within that window — this is replay protection. Because check_tonce! runs before signature verification, even a request that failed with 2005 (bad signature) has already burned its tonce.
Source
Thrown at app/api/api_v2/auth/authenticator.rb:39
def check_token!
raise InvalidAccessKeyError, @params[:access_key] unless token
raise DisabledAccessKeyError, @params[:access_key] if token.member.api_disabled
raise ExpiredAccessKeyError, @params[:access_key] if token.expired?
raise OutOfScopeError unless token.in_scopes?(route_scopes)
end
def check_signature!
if @params[:signature] != Utils.hmac_signature(token.secret_key, payload)
Rails.logger.warn "APIv2 auth failed: signature doesn't match. token: #{token.access_key} payload: #{payload}"
raise IncorrectSignatureError, @params[:signature]
end
end
def check_tonce!
key = "api_v2:tonce:#{token.access_key}:#{tonce}"
if Utils.cache.read(key)
Rails.logger.warn "APIv2 auth failed: used tonce. token: #{token.access_key} payload: #{payload} tonce: #{tonce}"
raise TonceUsedError.new(token.access_key, tonce)
end
Utils.cache.write key, tonce, 61 # forget after 61 seconds
now = Time.now.to_i*1000
if tonce < now-30000 || tonce > now+30000 # within 30 seconds
Rails.logger.warn "APIv2 auth failed: invalid tonce. token: #{token.access_key} payload: #{payload} tonce: #{tonce} current timestamp: #{now}"
raise InvalidTonceError.new(tonce, now)
end
end
def tonce
@tonce ||= @params[:tonce].to_i
end
def payload
"#{canonical_verb}|#{APIv2::Mount::PREFIX}#{canonical_uri}|#{canonical_query}"
end
View on GitHub (pinned to dab8641137)
Solutions
- Generate a fresh tonce per attempt: current epoch milliseconds plus a monotonic counter if you can burst more than one request per millisecond, and re-sign the payload with it
- Disable or reconfigure HTTP-client auto-retry so it can never replay the signed body unchanged
- When several processes sign concurrently, derive tonce from clock plus a per-process counter so two signers never emit the same value
Example fix
# before — tonce fixed once, replayed by a retry helper
tonce = Time.now.to_i * 1000
retryable.post('/api/v2/orders', params.merge(tonce: tonce, signature: sig))
# after — unique tonce on every attempt, re-sign before each resend
def next_tonce
@seq = ((@seq || 0) + 1) % 1_000
Time.now.to_i * 1_000_000 + @seq # unique even within the same millisecond
end
# on ANY failure (2005/2006/2007, timeouts): rebuild params with next_tonce and re-sign Defensive patterns
Strategy: retry
Validate before calling
# Track the last tonce per access key; never send a duplicate
def next_tonce(access_key)
@last ||= {}
t = Time.now.to_i * 1000
t += 1 while t <= @last[access_key] # guarantee strictly increasing
@last[access_key] = t
t
end Try / catch
On response code 2006, first verify the earlier attempt truly failed (query order/deposit state — the first request may have executed on the server); only then regenerate the tonce, re-sign the payload, and resend exactly once.
Prevention
- Never reuse a request body containing tonce/signature: retries rebuild both from scratch
- Disable HTTP-client auto-retry or configure it to re-invoke the signer
- Burst-heavy clients add a monotonic counter to millisecond timestamps
- Centralize tonce generation in one class instead of sprinkling Time.now calls across call sites
When it happens
Trigger: Resending a request verbatim after a timeout or a 2005/2007 auth failure; an HTTP client or proxy retry layer re-posting the identical signed body; two threads or workers sharing one tonce value; a tonce computed once at client startup and reused for every request in a loop.
Common situations: Idempotent-retry wrappers (faraday/axios retry interceptors, curl --retry in scripts) that replay the signed body unchanged; batch scripts pinning a constant tonce; a first attempt rejected for bad signature and a naive verbatim retry that then hits 2006 instead.
Related errors
AI-assisted analysis of hpyhacking/peatio@dab8641137 (2026-08-23).
Data as JSON: /api/errors/58151b42d11c624f.
Report an issue: GitHub.