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

  1. 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
  2. Disable or reconfigure HTTP-client auto-retry so it can never replay the signed body unchanged
  3. 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

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.