Stratum mining proxy library skeleton with 18 Go source files, type declarations, event bus, NiceHash/simple splitter packages, pool client, HTTP API types, access/share logging, and rate limiter. No function implementations — ready for agent dispatch. Co-Authored-By: Virgil <virgil@lethean.io>
52 KiB
| module | repo | lang | tier | depends | tags | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| forge.lthn.ai/core/go-proxy | core/go-proxy | go | lib |
|
|
go-proxy RFC — Stratum Mining Proxy
An agent should be able to implement this library from this document alone.
Module: forge.lthn.ai/core/go-proxy
Repository: core/go-proxy
Files: 18
1. Overview
go-proxy is a CryptoNote stratum protocol proxy library. It accepts miner connections over TCP (optionally TLS), splits the 32-bit nonce space across up to 256 simultaneous miners per upstream pool connection (NiceHash mode), and presents a small monitoring API.
The v1 scope covers:
- NiceHash nonce-splitting mode (256-slot table, one fixed byte per miner)
- Simple passthrough mode (one upstream per miner group, reuse on disconnect)
- Stratum JSON-RPC over TCP: login, job, submit, keepalived
- Algorithm negotiation extension (algo field in login + job)
- RigID extension (rigid field in login)
- Pool-side stratum client with primary/failover strategy
- TLS for inbound (miners) and outbound (pool)
- HTTP monitoring API: GET /1/summary, /1/workers, /1/miners
- Per-worker and per-miner stats with rolling hashrate windows (60s, 600s, 3600s, 12h, 24h)
- Access log and share log (append-only line files)
- JSON config file with hot-reload via filesystem watcher
- Connection rate limiting per IP (token bucket, configurable)
- Access password (applied to login params.pass)
- Custom difficulty override per-user (from login user suffix or global setting)
2. File Map
| File | Package | Purpose |
|---|---|---|
proxy.go |
proxy |
Proxy orchestrator — owns tick loop, listeners, splitter, stats |
config.go |
proxy |
Config struct, JSON unmarshal, hot-reload watcher |
server.go |
proxy |
TCP server — accepts connections, applies rate limiter |
miner.go |
proxy |
Miner state machine — one per connection |
job.go |
proxy |
Job value type — blob, job_id, target, algo, height |
worker.go |
proxy |
Worker aggregate — rolling hashrate, share counts |
stats.go |
proxy |
Stats aggregate — global counters, hashrate windows |
events.go |
proxy |
Event bus — LoginEvent, AcceptEvent, SubmitEvent, CloseEvent |
splitter/nicehash/splitter.go |
nicehash |
NonceSplitter — owns mapper pool, routes miners |
splitter/nicehash/mapper.go |
nicehash |
NonceMapper — one upstream connection, owns NonceStorage |
splitter/nicehash/storage.go |
nicehash |
NonceStorage — 256-slot table, fixed-byte allocation |
splitter/simple/splitter.go |
simple |
SimpleSplitter — passthrough, upstream reuse pool |
splitter/simple/mapper.go |
simple |
SimpleMapper — one upstream per miner group |
pool/client.go |
pool |
StratumClient — outbound pool TCP/TLS connection |
pool/strategy.go |
pool |
FailoverStrategy — primary + ordered fallbacks |
log/access.go |
log |
AccessLog — connection open/close lines |
log/share.go |
log |
ShareLog — accept/reject lines per share |
api/router.go |
api |
HTTP handlers — /1/summary, /1/workers, /1/miners |
3. Data Flow
Miners (TCP) → Server.accept()
→ ratelimit check (per-IP token bucket)
→ Miner.handleLogin()
→ Events.Dispatch(LoginEvent)
→ CustomDiff.Apply(miner) (sets miner.customDiff)
→ Workers.OnLogin(event) (upsert worker record)
→ Splitter.OnLogin(event) (assigns mapper slot)
→ NonceMapper.Add(miner)
→ NonceStorage.Add(miner) → miner.FixedByte = slot
→ if mapper active: miner.ForwardJob(currentJob)
Pool (TCP) → pool.StratumClient read loop → OnJob(job)
→ NonceMapper.onJob(job)
→ NonceStorage.SetJob(job)
→ for each active slot: miner.ForwardJob(job)
→ Miner sends JSON over TCP
Miner submit → Miner.handleSubmit()
→ Events.Dispatch(SubmitEvent)
→ Splitter.OnSubmit(event)
→ NonceMapper.Submit(event)
→ SubmitContext stored by sequence
→ pool.StratumClient.Submit(jobID, nonce, result, algo)
→ pool reply → OnResultAccepted(seq, ok, err)
→ Events.Dispatch(AcceptEvent | RejectEvent)
→ Workers.OnAccept / OnReject
→ Stats.OnAccept / OnReject
→ ShareLog.OnAccept / OnReject
→ Miner.Success or Miner.ReplyWithError
4. Config
// Config is the top-level proxy configuration, loaded from JSON and hot-reloaded on change.
//
// cfg, result := proxy.LoadConfig("config.json")
// if !result.OK { log.Fatal(result.Error) }
type Config struct {
Mode string `json:"mode"` // "nicehash" or "simple"
Bind []BindAddr `json:"bind"` // listen addresses
Pools []PoolConfig `json:"pools"` // ordered primary + fallbacks
TLS TLSConfig `json:"tls"` // inbound TLS (miner-facing)
HTTP HTTPConfig `json:"http"` // monitoring API
AccessPassword string `json:"access-password"` // "" = no auth required
CustomDiff uint64 `json:"custom-diff"` // 0 = disabled
CustomDiffStats bool `json:"custom-diff-stats"` // report per custom-diff bucket
AlgoExtension bool `json:"algo-ext"` // forward algo field in jobs
Workers WorkersMode `json:"workers"` // "rig-id", "user", "password", "agent", "ip", "false"
AccessLogFile string `json:"access-log-file"` // "" = disabled
ReuseTimeout int `json:"reuse-timeout"` // seconds; simple mode upstream reuse
Retries int `json:"retries"` // pool reconnect attempts
RetryPause int `json:"retry-pause"` // seconds between retries
Watch bool `json:"watch"` // hot-reload on file change
RateLimit RateLimit `json:"rate-limit"` // per-IP connection rate limit
}
// BindAddr is one TCP listen endpoint.
//
// proxy.BindAddr{Host: "0.0.0.0", Port: 3333, TLS: false}
type BindAddr struct {
Host string `json:"host"`
Port uint16 `json:"port"`
TLS bool `json:"tls"`
}
// PoolConfig is one upstream pool entry.
//
// proxy.PoolConfig{URL: "pool.lthn.io:3333", User: "WALLET", Pass: "x", Enabled: true}
type PoolConfig struct {
URL string `json:"url"`
User string `json:"user"`
Pass string `json:"pass"`
RigID string `json:"rig-id"`
Algo string `json:"algo"`
TLS bool `json:"tls"`
TLSFingerprint string `json:"tls-fingerprint"` // SHA-256 hex; "" = skip pin
Keepalive bool `json:"keepalive"`
Enabled bool `json:"enabled"`
}
// TLSConfig controls inbound TLS on bind addresses that have TLS: true.
//
// proxy.TLSConfig{Enabled: true, CertFile: "/etc/proxy/cert.pem", KeyFile: "/etc/proxy/key.pem"}
type TLSConfig struct {
Enabled bool `json:"enabled"`
CertFile string `json:"cert"`
KeyFile string `json:"cert_key"`
Ciphers string `json:"ciphers"` // OpenSSL cipher string; "" = default
Protocols string `json:"protocols"` // TLS version string; "" = default
}
// HTTPConfig controls the monitoring API server.
//
// proxy.HTTPConfig{Enabled: true, Host: "127.0.0.1", Port: 8080, Restricted: true}
type HTTPConfig struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port uint16 `json:"port"`
AccessToken string `json:"access-token"` // Bearer token; "" = no auth
Restricted bool `json:"restricted"` // true = read-only GET only
}
// RateLimit controls per-IP connection rate limiting using a token bucket.
//
// proxy.RateLimit{MaxConnectionsPerMinute: 30, BanDurationSeconds: 300}
type RateLimit struct {
MaxConnectionsPerMinute int `json:"max-connections-per-minute"` // 0 = disabled
BanDurationSeconds int `json:"ban-duration"` // 0 = no ban
}
// WorkersMode controls which login field becomes the worker name.
type WorkersMode string
const (
WorkersByRigID WorkersMode = "rig-id" // rigid field, fallback to user
WorkersByUser WorkersMode = "user"
WorkersByPass WorkersMode = "password"
WorkersByAgent WorkersMode = "agent"
WorkersByIP WorkersMode = "ip"
WorkersDisabled WorkersMode = "false"
)
// LoadConfig reads and unmarshals a JSON config file. Returns core.E on I/O or parse error.
//
// cfg, result := proxy.LoadConfig("config.json")
func LoadConfig(path string) (*Config, core.Result) {}
// Validate checks required fields. Returns core.E if pool list or bind list is empty,
// or if any enabled pool has an empty URL.
//
// if result := cfg.Validate(); !result.OK { return result }
func (c *Config) Validate() core.Result {}
5. Proxy Orchestrator
// Proxy is the top-level orchestrator. It owns the server, splitter, stats, workers,
// event bus, tick goroutine, and optional HTTP API.
//
// p, result := proxy.New(cfg)
// if result.OK { p.Start() }
type Proxy struct {
config *Config
splitter Splitter
stats *Stats
workers *Workers
events *EventBus
servers []*Server
ticker *time.Ticker
watcher *ConfigWatcher
done chan struct{}
}
// New creates and wires all subsystems but does not start the tick loop or TCP listeners.
//
// p, result := proxy.New(cfg)
func New(cfg *Config) (*Proxy, core.Result) {}
// Start begins the TCP listener(s), pool connections, tick loop, and (if configured) HTTP API.
// Blocks until Stop() is called.
//
// p.Start()
func (p *Proxy) Start() {}
// Stop shuts down all subsystems cleanly. Waits up to 5 seconds for in-flight submits to drain.
//
// p.Stop()
func (p *Proxy) Stop() {}
// Reload replaces the live config. Hot-reloads pool list and custom diff.
// Cannot change bind addresses at runtime (ignored if changed).
//
// p.Reload(newCfg)
func (p *Proxy) Reload(cfg *Config) {}
// Splitter is the interface both NonceSplitter and SimpleSplitter satisfy.
type Splitter interface {
// Connect establishes the first pool upstream connection.
Connect()
// OnLogin routes a newly authenticated miner to an upstream slot.
OnLogin(event *LoginEvent)
// OnSubmit routes a share submission to the correct upstream.
OnSubmit(event *SubmitEvent)
// OnClose releases the upstream slot for a disconnecting miner.
OnClose(event *CloseEvent)
// Tick is called every second for keepalive and GC housekeeping.
Tick(ticks uint64)
// GC runs every 60 ticks to reclaim disconnected upstream slots.
GC()
// Upstreams returns current upstream pool connection counts.
Upstreams() UpstreamStats
}
// UpstreamStats carries pool connection state counts for monitoring.
type UpstreamStats struct {
Active uint64 // connections currently receiving jobs
Sleep uint64 // idle connections (simple mode reuse pool)
Error uint64 // connections in error/reconnecting state
Total uint64 // Active + Sleep + Error
}
6. Miner Connection State Machine
Each accepted TCP connection is represented by a Miner. State transitions are linear:
WaitLogin → WaitReady → Ready → Closing
WaitLogin: connection open, awaitingloginrequest. 10-second timeout.WaitReady: login validated, awaiting upstream pool job. 600-second timeout.Ready: receiving jobs, accepting submit requests. 600-second inactivity timeout reset on each job or submit.Closing: TCP close in progress.
// MinerState represents the lifecycle state of one miner connection.
type MinerState int
const (
MinerStateWaitLogin MinerState = iota
MinerStateWaitReady
MinerStateReady
MinerStateClosing
)
// Miner is the state machine for one miner TCP connection.
//
// // created by Server on accept:
// m := proxy.NewMiner(conn, 3333, nil)
// m.Start()
type Miner struct {
id int64 // monotonically increasing per-process; atomic assignment
rpcID string // UUID v4 sent to miner as session id
state MinerState
extAlgo bool // miner sent algo list in login params
extNH bool // NiceHash mode active (fixed byte splitting)
ip string // remote IP (without port, for logging)
localPort uint16
user string // login params.login (wallet address), custom diff suffix stripped
password string // login params.pass
agent string // login params.agent
rigID string // login params.rigid (optional extension)
fixedByte uint8 // NiceHash slot index (0-255)
mapperID int64 // which NonceMapper owns this miner; -1 = unassigned
routeID int64 // SimpleMapper ID in simple mode; -1 = unassigned
customDiff uint64 // 0 = use pool diff; non-zero = cap diff to this value
diff uint64 // last difficulty sent to this miner from the pool
rx uint64 // bytes received from miner
tx uint64 // bytes sent to miner
connectedAt time.Time
lastActivityAt time.Time
conn net.Conn
tlsConn *tls.Conn // nil if plain TCP
sendMu sync.Mutex // serialises writes to conn
buf [16384]byte // per-miner send buffer; avoids per-write allocations
}
// NewMiner creates a Miner for an accepted net.Conn. Does not start reading yet.
//
// m := proxy.NewMiner(conn, 3333, nil)
// m := proxy.NewMiner(tlsConn, 3443, tlsCfg) // TLS variant
func NewMiner(conn net.Conn, localPort uint16, tlsCfg *tls.Config) *Miner {}
// Start begins the read loop in a goroutine and arms the login timeout timer.
//
// m.Start()
func (m *Miner) Start() {}
// ForwardJob encodes the job as a stratum `job` notification and writes it to the miner.
// In NiceHash mode, byte 39 of the blob hex (chars 78-79) is replaced with FixedByte.
//
// m.ForwardJob(job, "cn/r")
func (m *Miner) ForwardJob(job Job, algo string) {}
// ReplyWithError sends a JSON-RPC error response for the given request id.
//
// m.ReplyWithError(requestID, "Low difficulty share")
func (m *Miner) ReplyWithError(id int64, message string) {}
// Success sends a JSON-RPC success response with the given status string.
//
// m.Success(requestID, "OK")
func (m *Miner) Success(id int64, status string) {}
// Close initiates graceful TCP shutdown. Safe to call multiple times.
//
// m.Close()
func (m *Miner) Close() {}
6.1 Login Parsing
On login request arrival:
- Reject if
params.loginis empty:"Invalid payment address provided". - If
Config.AccessPassword != "", compare toparams.pass; reject if mismatch:"Invalid password". - Parse custom difficulty suffix: if
params.loginends with+{number}(e.g.WALLET+50000), strip the suffix, setminer.customDiff = number. If no suffix andConfig.CustomDiff > 0, setminer.customDiff = Config.CustomDiff. - Store
params.rigidasminer.rigIDif present. - Store
params.algolist; setminer.extAlgo = trueif list is non-empty. - Assign
miner.rpcID(UUID v4). - Dispatch
LoginEvent. - Transition to
WaitReady. - If a job is already available from upstream, send it immediately and transition to
Ready.
6.2 Submit Handling
On submit request arrival (state must be Ready):
- Validate
params.id == miner.rpcID; reject otherwise:"Unauthenticated". - Validate
params.job_idis non-empty; reject otherwise:"Missing job id". - Validate
params.nonceis exactly 8 lowercase hex characters; reject otherwise:"Invalid nonce". - Dispatch
SubmitEvent. The splitter performs upstream forwarding.
6.3 Keepalived Handling
On keepalived request arrival:
- Reset
lastActivityAtto now. - Reply
{"result": {"status": "KEEPALIVED"}}.
7. Job Value Type
// Job holds the current work unit received from a pool. Immutable once assigned.
//
// j := proxy.Job{
// Blob: "0707d5ef...b01",
// JobID: "4BiGm3/RgGQzgkTI",
// Target: "b88d0600",
// Algo: "cn/r",
// }
type Job struct {
Blob string // hex-encoded block template (160 hex chars = 80 bytes)
JobID string // pool-assigned identifier
Target string // 8-char hex little-endian uint32 difficulty target
Algo string // algorithm e.g. "cn/r", "rx/0"; "" if not negotiated
Height uint64 // block height (0 if pool did not provide)
SeedHash string // RandomX seed hash hex (empty if not RandomX)
ClientID string // pool session ID that issued this job (for stale detection)
}
// IsValid returns true if Blob and JobID are non-empty.
//
// if !job.IsValid() { return }
func (j Job) IsValid() bool {}
// BlobWithFixedByte returns a copy of Blob with hex characters at positions 78-79
// (blob byte index 39) replaced by the two-digit lowercase hex of fixedByte.
// Returns the original blob unchanged if len(Blob) < 80.
//
// partitioned := job.BlobWithFixedByte(0x2A) // chars 78-79 become "2a"
func (j Job) BlobWithFixedByte(fixedByte uint8) string {}
// DifficultyFromTarget converts the 8-char little-endian hex Target field to a uint64 difficulty.
// Formula: difficulty = 0xFFFFFFFF / uint32(target_le)
//
// diff := job.DifficultyFromTarget() // "b88d0600" → ~100000
func (j Job) DifficultyFromTarget() uint64 {}
8. NiceHash Splitter
The NiceHash splitter partitions the 32-bit nonce space among miners by fixing one byte (byte 39 of the blob). Each upstream pool connection (NonceMapper) owns a 256-slot table. Up to 256 miners share one pool connection. The 257th miner triggers creation of a new NonceMapper with a new pool connection.
8.1 NonceSplitter
// NonceSplitter is the Splitter implementation for NiceHash mode.
// It manages a dynamic slice of NonceMapper upstreams, creating new ones on demand.
//
// s := nicehash.NewNonceSplitter(cfg, eventBus, strategyFactory)
// s.Connect()
type NonceSplitter struct {
mappers []*NonceMapper
cfg *proxy.Config
events *proxy.EventBus
strategyFactory pool.StrategyFactory
mu sync.RWMutex
}
func NewNonceSplitter(cfg *proxy.Config, events *proxy.EventBus, factory pool.StrategyFactory) *NonceSplitter {}
// OnLogin assigns the miner to the first NonceMapper with a free slot.
// If all existing mappers are full, a new NonceMapper is created (new pool connection).
// Sets miner.MapperID to identify which mapper owns this miner.
//
// s.OnLogin(loginEvent)
func (s *NonceSplitter) OnLogin(event *proxy.LoginEvent) {}
// OnSubmit routes the submit to the NonceMapper identified by miner.MapperID.
//
// s.OnSubmit(submitEvent)
func (s *NonceSplitter) OnSubmit(event *proxy.SubmitEvent) {}
// OnClose removes the miner from its mapper's slot.
//
// s.OnClose(closeEvent)
func (s *NonceSplitter) OnClose(event *proxy.CloseEvent) {}
// GC removes NonceMappers that are empty and have been idle more than 60 seconds.
//
// s.GC() // called by Proxy tick loop every 60 ticks
func (s *NonceSplitter) GC() {}
8.2 NonceMapper
// NonceMapper manages one outbound pool connection and the 256-slot NonceStorage.
// It implements pool.StratumListener to receive job and result events from the pool.
//
// m := nicehash.NewNonceMapper(id, cfg, strategy)
// m.Start()
type NonceMapper struct {
id int64
storage *NonceStorage
strategy pool.Strategy // manages pool client lifecycle and failover
pending map[int64]SubmitContext // sequence → {requestID, minerID}
cfg *proxy.Config
active bool // true once pool has sent at least one job
suspended int // > 0 when pool connection is in error/reconnecting
mu sync.Mutex
}
// SubmitContext tracks one in-flight share submission waiting for pool reply.
type SubmitContext struct {
RequestID int64 // JSON-RPC id from the miner's submit request
MinerID int64 // miner that submitted
}
func NewNonceMapper(id int64, cfg *proxy.Config, strategy pool.Strategy) *NonceMapper {}
// Add assigns a miner to a free slot. Returns false if all 256 slots are occupied.
// Sets miner.FixedByte to the allocated slot index.
//
// ok := mapper.Add(miner)
func (m *NonceMapper) Add(miner *proxy.Miner) bool {}
// Remove releases the miner's slot (marks it dead until next job clears it).
//
// mapper.Remove(miner)
func (m *NonceMapper) Remove(miner *proxy.Miner) {}
// Submit forwards a share to the pool. Stores a SubmitContext keyed by the pool sequence number
// so the pool reply can be correlated back to the originating miner.
//
// mapper.Submit(submitEvent)
func (m *NonceMapper) Submit(event *proxy.SubmitEvent) {}
// IsActive returns true when the pool has delivered at least one valid job.
//
// if mapper.IsActive() { /* safe to assign miners */ }
func (m *NonceMapper) IsActive() bool {}
8.3 NonceStorage
// NonceStorage is the 256-slot fixed-byte allocation table for one NonceMapper.
//
// Slot encoding:
// 0 = free
// +minerID = active miner
// -minerID = disconnected miner (dead slot, cleared on next SetJob)
//
// storage := nicehash.NewNonceStorage()
type NonceStorage struct {
slots [256]int64 // slot state per above encoding
miners map[int64]*proxy.Miner // minerID → Miner pointer for active miners
job proxy.Job // current job from pool
prevJob proxy.Job // previous job (for stale submit validation)
cursor int // search starts here (round-robin allocation)
mu sync.Mutex
}
func NewNonceStorage() *NonceStorage {}
// Add finds the next free slot starting from cursor (wrapping), sets slot[index] = minerID,
// sets miner.FixedByte = index. Returns false if all 256 slots are occupied (active or dead).
//
// ok := storage.Add(miner)
func (s *NonceStorage) Add(miner *proxy.Miner) bool {}
// Remove marks slot[miner.FixedByte] = -minerID (dead). Does not clear immediately;
// dead slots are cleared by the next SetJob call.
//
// storage.Remove(miner)
func (s *NonceStorage) Remove(miner *proxy.Miner) {}
// SetJob replaces the current job. Clears all dead slots (sets them to 0).
// Archives current job to prevJob (same ClientID) or resets prevJob (new pool).
// Sends the new job (with per-slot blob patch) to every active miner.
//
// storage.SetJob(job)
func (s *NonceStorage) SetJob(job proxy.Job) {}
// IsValidJobID returns true if id matches the current or previous job ID.
// A match on the previous job increments the global expired counter and still returns true
// (the share is accepted but flagged as expired in stats).
//
// if !storage.IsValidJobID(submitJobID) { reject }
func (s *NonceStorage) IsValidJobID(id string) bool {}
// SlotCount returns free, dead, and active slot counts for monitoring output.
//
// free, dead, active := storage.SlotCount()
func (s *NonceStorage) SlotCount() (free, dead, active int) {}
9. Simple Splitter
Simple mode creates one upstream pool connection per miner. When ReuseTimeout > 0, the upstream connection is held idle for that many seconds after the miner disconnects, allowing the next miner to inherit it and avoid reconnect latency.
// SimpleSplitter is the Splitter implementation for simple (passthrough) mode.
//
// s := simple.NewSimpleSplitter(cfg, eventBus, strategyFactory)
type SimpleSplitter struct {
active map[int64]*SimpleMapper // minerID → mapper
idle map[int64]*SimpleMapper // mapperID → mapper (reuse pool, keyed by mapper seq)
cfg *proxy.Config
events *proxy.EventBus
factory pool.StrategyFactory
mu sync.Mutex
seq int64 // monotonic mapper sequence counter
}
func NewSimpleSplitter(cfg *proxy.Config, events *proxy.EventBus, factory pool.StrategyFactory) *SimpleSplitter {}
// OnLogin creates a new SimpleMapper, or reclaims one from the idle pool if
// ReuseTimeout > 0 and an idle mapper's pool connection is still active.
// Sets miner.RouteID to the mapper's sequence ID.
//
// s.OnLogin(event)
func (s *SimpleSplitter) OnLogin(event *proxy.LoginEvent) {}
// OnSubmit forwards to the mapper owning this miner (looked up by miner.RouteID).
//
// s.OnSubmit(event)
func (s *SimpleSplitter) OnSubmit(event *proxy.SubmitEvent) {}
// OnClose moves the mapper to the idle pool (if ReuseTimeout > 0) or stops it immediately.
//
// s.OnClose(event)
func (s *SimpleSplitter) OnClose(event *proxy.CloseEvent) {}
// GC removes idle mappers whose idle duration exceeds ReuseTimeout, and stopped mappers.
//
// s.GC()
func (s *SimpleSplitter) GC() {}
// SimpleMapper holds one outbound pool connection and serves at most one active miner at a time.
// It becomes idle when the miner disconnects and may be reclaimed for the next login.
//
// m := simple.NewSimpleMapper(id, strategy)
type SimpleMapper struct {
id int64
miner *proxy.Miner // nil when idle
strategy pool.Strategy
idleAt time.Time // zero when active
stopped bool
}
10. Pool Client
10.1 StratumClient
// StratumClient is one outbound stratum TCP (optionally TLS) connection to a pool.
// The proxy presents itself to the pool as a standard stratum miner using the
// wallet address and password from PoolConfig.
//
// client := pool.NewStratumClient(poolCfg, listener)
// client.Connect()
type StratumClient struct {
cfg PoolConfig
listener StratumListener
conn net.Conn
tlsConn *tls.Conn // nil if plain TCP
sessionID string // pool-assigned session id from login reply
seq int64 // atomic JSON-RPC request id counter
active bool // true once first job received
sendMu sync.Mutex
}
// StratumListener receives events from the pool connection.
type StratumListener interface {
// OnJob is called when the pool pushes a new job notification or the login reply contains a job.
OnJob(job proxy.Job)
// OnResultAccepted is called when the pool accepts or rejects a submitted share.
// sequence matches the value returned by Submit(). errorMessage is "" on accept.
OnResultAccepted(sequence int64, accepted bool, errorMessage string)
// OnDisconnect is called when the pool TCP connection closes for any reason.
OnDisconnect()
}
func NewStratumClient(cfg PoolConfig, listener StratumListener) *StratumClient {}
// Connect dials the pool. Applies TLS if cfg.TLS is true.
// If cfg.TLSFingerprint is non-empty, pins the server certificate by SHA-256 of DER bytes.
//
// result := client.Connect()
func (c *StratumClient) Connect() core.Result {}
// Login sends the stratum login request using cfg.User and cfg.Pass.
// The reply triggers StratumListener.OnJob when the pool's first job arrives.
//
// client.Login()
func (c *StratumClient) Login() {}
// Submit sends a share submission. Returns the sequence number for result correlation.
// algo is "" if algorithm extension is not active.
//
// seq := client.Submit(jobID, "deadbeef", "HASH64HEX", "cn/r")
func (c *StratumClient) Submit(jobID, nonce, result, algo string) int64 {}
// Disconnect closes the connection cleanly. Triggers OnDisconnect on the listener.
//
// client.Disconnect()
func (c *StratumClient) Disconnect() {}
10.2 FailoverStrategy
// FailoverStrategy wraps an ordered slice of PoolConfig entries.
// It connects to the first enabled pool and fails over in order on error.
// On reconnect it always retries from the primary first.
//
// strategy := pool.NewFailoverStrategy(cfg.Pools, listener, cfg)
// strategy.Connect()
type FailoverStrategy struct {
pools []PoolConfig
current int
client *StratumClient
listener StratumListener
cfg *proxy.Config
mu sync.Mutex
}
// StrategyFactory creates a new FailoverStrategy for a given StratumListener.
// Used by splitters to create per-mapper strategies without coupling to Config.
//
// factory := pool.NewStrategyFactory(cfg)
// strategy := factory(listener) // each mapper calls this
type StrategyFactory func(listener StratumListener) Strategy
// Strategy is the interface the splitters use to submit shares and check pool state.
type Strategy interface {
Connect()
Submit(jobID, nonce, result, algo string) int64
Disconnect()
IsActive() bool
}
func NewFailoverStrategy(pools []PoolConfig, listener StratumListener, cfg *proxy.Config) *FailoverStrategy {}
// Connect dials the current pool. On failure, advances to the next pool (modulo len),
// respecting cfg.Retries and cfg.RetryPause between attempts.
//
// strategy.Connect()
func (s *FailoverStrategy) Connect() {}
11. Event Bus
// EventBus dispatches proxy lifecycle events to registered listeners.
// Dispatch is synchronous on the calling goroutine. Listeners must not block.
//
// bus := proxy.NewEventBus()
// bus.Subscribe(proxy.EventLogin, customDiff.OnLogin)
// bus.Subscribe(proxy.EventAccept, stats.OnAccept)
type EventBus struct {
listeners map[EventType][]EventHandler
mu sync.RWMutex
}
// EventType identifies the proxy lifecycle event.
type EventType int
const (
EventLogin EventType = iota // miner completed login
EventAccept // pool accepted a submitted share
EventReject // pool rejected a share (or share expired)
EventClose // miner TCP connection closed
)
// EventHandler is the callback signature for all event types.
type EventHandler func(Event)
// Event carries the data for any proxy lifecycle event.
// Fields not relevant to the event type are zero/nil.
type Event struct {
Type EventType
Miner *Miner // always set
Job *Job // set for Accept and Reject events
Diff uint64 // effective difficulty of the share (Accept and Reject)
Error string // rejection reason (Reject only)
Latency uint16 // pool response time in ms (Accept and Reject)
Expired bool // true if the share was accepted but against the previous job
}
func NewEventBus() *EventBus {}
// Subscribe registers a handler for the given event type. Safe to call before Start.
//
// bus.Subscribe(proxy.EventAccept, func(e proxy.Event) { stats.OnAccept(e.Diff) })
func (b *EventBus) Subscribe(t EventType, h EventHandler) {}
// Dispatch calls all registered handlers for the event's type in subscription order.
//
// bus.Dispatch(proxy.Event{Type: proxy.EventLogin, Miner: m})
func (b *EventBus) Dispatch(e Event) {}
12. Stats
// Stats tracks global proxy metrics. Hot-path counters are atomic. Hashrate windows
// use a ring buffer per window size, advanced by Tick().
//
// s := proxy.NewStats()
// bus.Subscribe(proxy.EventAccept, s.OnAccept)
// bus.Subscribe(proxy.EventReject, s.OnReject)
type Stats struct {
accepted atomic.Uint64
rejected atomic.Uint64
invalid atomic.Uint64
expired atomic.Uint64
hashes atomic.Uint64 // cumulative sum of accepted share difficulties
connections atomic.Uint64 // total TCP connections accepted (ever)
maxMiners atomic.Uint64 // peak concurrent miner count
topDiff [10]uint64 // top-10 accepted difficulties, sorted descending; guarded by mu
latency []uint16 // pool response latencies in ms; capped at 10000 samples; guarded by mu
windows [6]tickWindow // one per hashrate reporting period (see constants below)
startTime time.Time
mu sync.Mutex
}
// Hashrate window sizes in seconds. Index maps to Stats.windows and SummaryResponse.Hashrate.
const (
HashrateWindow60s = 0 // 1 minute
HashrateWindow600s = 1 // 10 minutes
HashrateWindow3600s = 2 // 1 hour
HashrateWindow12h = 3 // 12 hours
HashrateWindow24h = 4 // 24 hours
HashrateWindowAll = 5 // all-time (single accumulator, no window)
)
// tickWindow is a fixed-capacity ring buffer of per-second difficulty sums.
type tickWindow struct {
buckets []uint64
pos int
size int // window size in seconds = len(buckets)
}
// StatsSummary is the serialisable snapshot returned by Summary().
type StatsSummary struct {
Accepted uint64 `json:"accepted"`
Rejected uint64 `json:"rejected"`
Invalid uint64 `json:"invalid"`
Expired uint64 `json:"expired"`
Hashes uint64 `json:"hashes_total"`
AvgTime uint32 `json:"avg_time"` // seconds per accepted share
AvgLatency uint32 `json:"latency"` // median pool response latency in ms
Hashrate [6]float64 `json:"hashrate"` // H/s per window (index = HashrateWindow* constants)
TopDiff [10]uint64 `json:"best"`
}
func NewStats() *Stats {}
// OnAccept records an accepted share. Adds diff to the current second's bucket in all windows.
//
// stats.OnAccept(proxy.Event{Diff: 100000, Latency: 82})
func (s *Stats) OnAccept(e proxy.Event) {}
// OnReject records a rejected share. If e.Error indicates low diff or malformed, increments invalid.
//
// stats.OnReject(proxy.Event{Error: "Low difficulty share"})
func (s *Stats) OnReject(e proxy.Event) {}
// Tick advances all rolling windows by one second bucket. Called by the proxy tick loop.
//
// stats.Tick()
func (s *Stats) Tick() {}
// Summary returns a point-in-time snapshot of all stats fields for API serialisation.
//
// summary := stats.Summary()
func (s *Stats) Summary() StatsSummary {}
13. Workers
Workers are aggregate identity records built from miner login fields. Which field becomes the worker name is controlled by Config.Workers (WorkersMode).
// Workers maintains per-worker aggregate stats. Workers are identified by name,
// derived from the miner's login fields per WorkersMode.
//
// w := proxy.NewWorkers(proxy.WorkersByRigID, bus)
type Workers struct {
mode WorkersMode
entries []WorkerRecord // ordered by first-seen (stable)
nameIndex map[string]int // workerName → entries index
idIndex map[int64]int // minerID → entries index
mu sync.RWMutex
}
// WorkerRecord is the per-identity aggregate.
type WorkerRecord struct {
Name string
LastIP string
Connections uint64
Accepted uint64
Rejected uint64
Invalid uint64
Hashes uint64 // sum of accepted share difficulties
LastHashAt time.Time
windows [5]tickWindow // 60s, 600s, 3600s, 12h, 24h
}
// Hashrate returns the H/s for a given window (seconds: 60, 600, 3600, 43200, 86400).
//
// hr60 := record.Hashrate(60)
func (r *WorkerRecord) Hashrate(seconds int) float64 {}
func NewWorkers(mode WorkersMode, bus *proxy.EventBus) *Workers {}
// List returns a snapshot of all worker records in first-seen order.
//
// workers := w.List()
func (w *Workers) List() []WorkerRecord {}
// Tick advances all worker hashrate windows. Called by the proxy tick loop every second.
//
// w.Tick()
func (w *Workers) Tick() {}
14. Custom Difficulty
// CustomDiff resolves and applies per-miner difficulty overrides at login time.
// Resolution order: user-suffix (+N) > Config.CustomDiff > pool difficulty.
//
// cd := proxy.NewCustomDiff(cfg.CustomDiff)
// bus.Subscribe(proxy.EventLogin, cd.OnLogin)
type CustomDiff struct {
globalDiff uint64
}
func NewCustomDiff(globalDiff uint64) *CustomDiff {}
// OnLogin parses miner.User for a "+{number}" suffix and sets miner.CustomDiff.
// Strips the suffix from miner.User so the clean wallet address is forwarded to the pool.
// Falls back to globalDiff if no suffix is present.
//
// cd.OnLogin(event)
// // "WALLET+50000" → miner.User="WALLET", miner.CustomDiff=50000
// // "WALLET" → miner.User="WALLET", miner.CustomDiff=globalDiff (if >0)
func (cd *CustomDiff) OnLogin(e proxy.Event) {}
15. Logging
15.1 AccessLog
// AccessLog writes connection lifecycle lines to an append-only text file.
//
// Line format (connect): 2026-04-04T12:00:00Z CONNECT <ip> <user> <agent>
// Line format (close): 2026-04-04T12:00:00Z CLOSE <ip> <user> rx=<bytes> tx=<bytes>
//
// al, result := log.NewAccessLog("/var/log/proxy-access.log")
// bus.Subscribe(proxy.EventLogin, al.OnLogin)
// bus.Subscribe(proxy.EventClose, al.OnClose)
type AccessLog struct {
path string
mu sync.Mutex
f core.File // opened append-only on first write; nil until first event
}
func NewAccessLog(path string) *AccessLog {}
// OnLogin writes a CONNECT line. Called synchronously from the event bus.
//
// al.OnLogin(event)
func (l *AccessLog) OnLogin(e proxy.Event) {}
// OnClose writes a CLOSE line with byte counts.
//
// al.OnClose(event)
func (l *AccessLog) OnClose(e proxy.Event) {}
15.2 ShareLog
// ShareLog writes share result lines to an append-only text file.
//
// Line format (accept): 2026-04-04T12:00:00Z ACCEPT <user> diff=<diff> latency=<ms>ms
// Line format (reject): 2026-04-04T12:00:00Z REJECT <user> reason="<message>"
//
// sl := log.NewShareLog("/var/log/proxy-shares.log")
// bus.Subscribe(proxy.EventAccept, sl.OnAccept)
// bus.Subscribe(proxy.EventReject, sl.OnReject)
type ShareLog struct {
path string
mu sync.Mutex
f core.File
}
func NewShareLog(path string) *ShareLog {}
// OnAccept writes an ACCEPT line.
//
// sl.OnAccept(event)
func (l *ShareLog) OnAccept(e proxy.Event) {}
// OnReject writes a REJECT line with the rejection reason.
//
// sl.OnReject(event)
func (l *ShareLog) OnReject(e proxy.Event) {}
16. HTTP Monitoring API
// RegisterRoutes registers the proxy monitoring routes on a core/api Router.
// GET /1/summary — aggregated proxy stats
// GET /1/workers — per-worker hashrate table
// GET /1/miners — per-connection state table
//
// proxyapi.RegisterRoutes(apiRouter, p)
func RegisterRoutes(r api.Router, p *proxy.Proxy) {}
GET /1/summary — response shape
{
"version": "1.0.0",
"mode": "nicehash",
"hashrate": {
"total": [12345.67, 11900.00, 12100.00, 11800.00, 12000.00, 12200.00]
},
"miners": {"now": 142, "max": 200},
"workers": 38,
"upstreams": {"active": 1, "sleep": 0, "error": 0, "total": 1, "ratio": 142.0},
"results": {
"accepted": 4821, "rejected": 3, "invalid": 0, "expired": 12,
"avg_time": 47, "latency": 82, "hashes_total": 4821000000,
"best": [999999999, 888888888, 777777777, 0, 0, 0, 0, 0, 0, 0]
}
}
Hashrate array index → window: [0]=60s, [1]=600s, [2]=3600s, [3]=12h, [4]=24h, [5]=all-time.
GET /1/workers — response shape
{
"mode": "rig-id",
"workers": [
["rig-alpha", "10.0.0.1", 2, 1240, 1, 0, 124000000000, 1712232000, 4321.0, 4200.0, 4100.0, 4050.0, 4000.0]
]
}
Worker array columns: name, last_ip, connections, accepted, rejected, invalid, hashes, last_hash_unix, hashrate_60s, hashrate_600s, hashrate_3600s, hashrate_12h, hashrate_24h.
GET /1/miners — response shape
{
"format": ["id","ip","tx","rx","state","diff","user","password","rig_id","agent"],
"miners": [
[1, "10.0.0.1:49152", 4096, 512, 2, 100000, "WALLET", "********", "rig-alpha", "XMRig/6.21.0"]
]
}
state values: 0=WaitLogin, 1=WaitReady, 2=Ready, 3=Closing. Password is always "********".
// SummaryResponse is the /1/summary JSON body.
type SummaryResponse struct {
Version string `json:"version"`
Mode string `json:"mode"`
Hashrate HashrateResponse `json:"hashrate"`
Miners MinersCountResponse `json:"miners"`
Workers uint64 `json:"workers"`
Upstreams UpstreamResponse `json:"upstreams"`
Results ResultsResponse `json:"results"`
}
type HashrateResponse struct { Total [6]float64 `json:"total"` }
type MinersCountResponse struct { Now uint64 `json:"now"`; Max uint64 `json:"max"` }
type UpstreamResponse struct {
Active uint64 `json:"active"`
Sleep uint64 `json:"sleep"`
Error uint64 `json:"error"`
Total uint64 `json:"total"`
Ratio float64 `json:"ratio"`
}
type ResultsResponse struct {
Accepted uint64 `json:"accepted"`
Rejected uint64 `json:"rejected"`
Invalid uint64 `json:"invalid"`
Expired uint64 `json:"expired"`
AvgTime uint32 `json:"avg_time"`
Latency uint32 `json:"latency"`
HashesTotal uint64 `json:"hashes_total"`
Best [10]uint64 `json:"best"`
}
17. Server and Rate Limiter
// Server listens on one BindAddr and creates a Miner for each accepted connection.
//
// srv, result := proxy.NewServer(bind, tlsCfg, rateLimiter, onAccept)
// srv.Start()
type Server struct {
addr BindAddr
tlsCfg *tls.Config // nil for plain TCP
limiter *RateLimiter
onAccept func(net.Conn, uint16)
listener net.Listener
done chan struct{}
}
func NewServer(bind BindAddr, tlsCfg *tls.Config, limiter *RateLimiter, onAccept func(net.Conn, uint16)) (*Server, core.Result) {}
// Start begins accepting connections in a goroutine.
//
// srv.Start()
func (s *Server) Start() {}
// Stop closes the listener. In-flight connections are not forcibly closed.
//
// srv.Stop()
func (s *Server) Stop() {}
// RateLimiter implements per-IP token bucket connection rate limiting.
// Each unique IP has a bucket initialised to MaxConnectionsPerMinute tokens.
// Each connection attempt consumes one token. Tokens refill at 1 per (60/max) seconds.
// An IP that empties its bucket is added to a ban list for BanDurationSeconds.
//
// rl := proxy.NewRateLimiter(cfg.RateLimit)
// if !rl.Allow("1.2.3.4") { conn.Close(); return }
type RateLimiter struct {
cfg RateLimit
buckets map[string]*tokenBucket
banned map[string]time.Time
mu sync.Mutex
}
// tokenBucket is a simple token bucket for one IP.
type tokenBucket struct {
tokens int
lastRefill time.Time
}
func NewRateLimiter(cfg RateLimit) *RateLimiter {}
// Allow returns true if the IP address is permitted to open a new connection. Thread-safe.
// Extracts the host from ip (strips port if present).
//
// if rl.Allow(conn.RemoteAddr().String()) { proceed() }
func (rl *RateLimiter) Allow(ip string) bool {}
// Tick removes expired ban entries and refills all token buckets. Called every second.
//
// rl.Tick()
func (rl *RateLimiter) Tick() {}
18. Config Hot-Reload
// ConfigWatcher polls a config file for mtime changes and calls onChange on modification.
// Uses 1-second polling; does not require fsnotify.
//
// w := proxy.NewConfigWatcher("config.json", func(cfg *proxy.Config) {
// p.Reload(cfg)
// })
// w.Start()
type ConfigWatcher struct {
path string
onChange func(*Config)
lastMod time.Time
done chan struct{}
}
func NewConfigWatcher(path string, onChange func(*Config)) *ConfigWatcher {}
// Start begins the polling goroutine. No-op if Watch is false in config.
//
// w.Start()
func (w *ConfigWatcher) Start() {}
// Stop ends the polling goroutine cleanly.
//
// w.Stop()
func (w *ConfigWatcher) Stop() {}
19. Stratum Wire Format
All stratum messages are newline-delimited JSON (\n terminated). Maximum line length is 16384 bytes. The proxy reads with a buffered line reader that discards lines exceeding this limit and closes the connection.
Miner → Proxy (login)
{"id":1,"jsonrpc":"2.0","method":"login","params":{"login":"WALLET","pass":"x","agent":"XMRig/6.21.0","algo":["cn/r","rx/0"],"rigid":"rig-name"}}
Miner → Proxy (submit)
{"id":2,"jsonrpc":"2.0","method":"submit","params":{"id":"SESSION-UUID","job_id":"JOBID","nonce":"deadbeef","result":"HASH64HEX","algo":"cn/r"}}
Miner → Proxy (keepalived)
{"id":3,"method":"keepalived","params":{"id":"SESSION-UUID"}}
Proxy → Miner (login success, with algo extension)
{"id":1,"jsonrpc":"2.0","error":null,"result":{"id":"SESSION-UUID","job":{"blob":"BLOB160HEX","job_id":"JOBID","target":"b88d0600","algo":"cn/r","id":"SESSION-UUID"},"extensions":["algo"],"status":"OK"}}
Proxy → Miner (job notification)
{"jsonrpc":"2.0","method":"job","params":{"blob":"BLOB160HEX","job_id":"JOBID","target":"b88d0600","algo":"cn/r","id":"SESSION-UUID"}}
Proxy → Miner (submit success)
{"id":2,"jsonrpc":"2.0","error":null,"result":{"status":"OK"}}
Proxy → Miner (error)
{"id":1,"jsonrpc":"2.0","error":{"code":-1,"message":"Invalid payment address provided"}}
Proxy → Miner (keepalived reply)
{"id":3,"jsonrpc":"2.0","error":null,"result":{"status":"KEEPALIVED"}}
NiceHash Nonce Patching Detail
Before sending a job to a miner in NiceHash mode, byte index 39 of the blob (hex characters at positions 78–79, zero-indexed) is replaced with the miner's fixedByte rendered as two lowercase hex digits.
Example: original blob "07070000...0000" (160 chars), fixedByte 0x2A → positions 78–79 become "2a".
When the miner submits, the nonce field will contain fixedByte at byte 39 naturally (the miner searched only its partition). The proxy forwards the nonce to the pool without modification — the pool sees valid nonces from the full 32-bit space partitioned correctly by the fixed byte scheme.
20. Concurrency Model
The proxy uses per-miner goroutines rather than an event loop. Each accepted connection runs in its own goroutine for reading. Writes to a miner connection are serialised by Miner.sendMu.
| Shared resource | Protection |
|---|---|
Stats hot counters (accepted, rejected, hashes) |
atomic.Uint64 — no lock |
Stats.topDiff, Stats.latency |
Stats.mu Mutex |
Stats.windows |
Single writer (tick loop goroutine) — no lock needed |
Workers read (List, Tick) |
Workers.mu RWMutex |
Workers write (OnLogin, OnAccept) |
Workers.mu RWMutex |
NonceStorage |
Per-storage sync.Mutex |
EventBus subscriptions |
EventBus.mu RWMutex; dispatch holds read lock |
RateLimiter |
RateLimiter.mu Mutex |
Miner TCP writes |
Miner.sendMu Mutex |
StratumClient TCP writes |
StratumClient.sendMu Mutex |
NonceSplitter.mappers slice |
NonceSplitter.mu RWMutex |
SimpleSplitter.active/idle maps |
SimpleSplitter.mu Mutex |
The tick loop is a single goroutine on a 1-second interval. It calls Stats.Tick(), Workers.Tick(), and RateLimiter.Tick() sequentially without further synchronisation.
The pool StratumClient read loop runs in its own goroutine. Submit() and Disconnect() serialise through sendMu.
21. Error Handling
All errors use core.E(scope, message, cause). No fmt.Errorf, errors.New, log, or os imports.
| Error condition | Handling |
|---|---|
| Config parse failure | core.E("proxy.config", "parse failed", err) — fatal, proxy does not start |
| Bind address in use | core.E("proxy.server", "listen failed", err) — fatal |
| TLS cert/key load failure | core.E("proxy.tls", "load certificate failed", err) — fatal |
| Login timeout (10s) | Close connection silently |
| Inactivity timeout (600s) | Close connection with WARN log |
| Line too long (>16384 bytes) | Close connection with WARN log |
| Pool connect failure | Retry via FailoverStrategy; miners in WaitReady remain waiting |
| Pool sends invalid job | Drop job; log at WARN |
| Submit sequence mismatch | Log at WARN; reply error to miner |
| Nonce table full (256/256) | Reject login: "Proxy is full, try again later" |
| Access password mismatch | Reject login: "Invalid password" |
| TLS fingerprint mismatch | Close outbound connection; log at ERROR; try next pool |
22. Tests
Test naming: TestFilename_Function_{Good,Bad,Ugly} — all three mandatory per function.
Unit Tests
storage_test.go
// TestStorage_Add_Good: 256 sequential Add calls fill all slots; cursor wraps correctly;
// each miner.FixedByte is unique 0-255.
// TestStorage_Add_Bad: 257th Add returns false when table is full.
// TestStorage_Add_Ugly: Add, Remove, SetJob, Add — removed slot is reclaimed (was dead, now free).
func TestStorage_Add_Good(t *testing.T) {
s := nicehash.NewNonceStorage()
seen := make(map[uint8]bool)
for i := 0; i < 256; i++ {
m := &proxy.Miner{}
m.SetID(int64(i + 1))
ok := s.Add(m)
require.True(t, ok)
require.False(t, seen[m.FixedByte()])
seen[m.FixedByte()] = true
}
}
job_test.go
// TestJob_BlobWithFixedByte_Good: 160-char blob, fixedByte 0x2A → chars[78:80] == "2a", total len 160.
// TestJob_BlobWithFixedByte_Bad: blob shorter than 80 chars → returns original blob unchanged.
// TestJob_BlobWithFixedByte_Ugly: fixedByte 0xFF → "ff" (lowercase, not "FF").
func TestJob_BlobWithFixedByte_Good(t *testing.T) {
j := proxy.Job{Blob: core.RepeatString("0", 160)}
result := j.BlobWithFixedByte(0x2A)
require.Equal(t, "2a", result[78:80])
require.Equal(t, 160, len(result))
}
stats_test.go
// TestStats_OnAccept_Good: accepted counter +1, hashes += diff, topDiff updated.
// TestStats_OnAccept_Bad: 100 concurrent goroutines each calling OnAccept — no race (-race flag).
// TestStats_OnAccept_Ugly: 15 accepts with varying diffs — topDiff[9] is the 10th highest, not 0.
customdiff_test.go
// TestCustomDiff_Apply_Good: "WALLET+50000" → user="WALLET", customDiff=50000.
// TestCustomDiff_Apply_Bad: "WALLET+abc" → user unchanged "WALLET+abc", customDiff=0.
// TestCustomDiff_Apply_Ugly: globalDiff=10000, user has no suffix → customDiff=10000.
ratelimit_test.go
// TestRateLimiter_Allow_Good: budget of 10/min, first 10 calls return true.
// TestRateLimiter_Allow_Bad: 11th call returns false (bucket exhausted).
// TestRateLimiter_Allow_Ugly: banned IP returns false for BanDurationSeconds even with new bucket.
job_difficulty_test.go
// TestJob_DifficultyFromTarget_Good: target "b88d0600" → difficulty 100000 (known value).
// TestJob_DifficultyFromTarget_Bad: target "00000000" → difficulty 0 (no divide by zero panic).
// TestJob_DifficultyFromTarget_Ugly: target "ffffffff" → difficulty 1 (minimum).
Integration Tests
integration/nicehash_test.go — in-process mock pool server
// TestNicehash_FullFlow_Good: start proxy, connect 3 miners, receive job broadcast,
// submit share, verify accept reply and stats.accepted == 1.
// TestNicehash_FullFlow_Bad: submit with wrong session id → error reply "Unauthenticated",
// miner connection stays open.
// TestNicehash_FullFlow_Ugly: 256 miners connected → 257th login rejected "Proxy is full".
integration/failover_test.go
// TestFailover_PrimaryDown_Good: primary pool closes listener, proxy reconnects to fallback
// within 3 seconds, miners receive next job.
// TestFailover_AllDown_Bad: all pool addresses unavailable, miners in WaitReady stay open
// (no spurious close on the miner side).
// TestFailover_Recovery_Ugly: primary recovers while proxy is using fallback,
// next reconnect attempt uses primary (not fallback).
23. Example config.json
{
"mode": "nicehash",
"bind": [
{"host": "0.0.0.0", "port": 3333, "tls": false},
{"host": "0.0.0.0", "port": 3443, "tls": true}
],
"pools": [
{"url": "pool.lthn.io:3333", "user": "WALLET", "pass": "x", "tls": false, "enabled": true},
{"url": "pool-backup.lthn.io:3333", "user": "WALLET", "pass": "x", "tls": false, "enabled": true}
],
"tls": {
"enabled": true,
"cert": "/etc/proxy/cert.pem",
"cert_key": "/etc/proxy/key.pem"
},
"http": {
"enabled": true,
"host": "127.0.0.1",
"port": 8080,
"access-token": "secret",
"restricted": true
},
"access-password": "",
"access-log-file": "/var/log/proxy-access.log",
"custom-diff": 0,
"custom-diff-stats": false,
"algo-ext": true,
"workers": "rig-id",
"rate-limit": {"max-connections-per-minute": 30, "ban-duration": 300},
"retries": 3,
"retry-pause": 2,
"reuse-timeout": 0,
"watch": true
}
24. Reference
| Resource | Location |
|---|---|
| Core Go RFC | code/core/go/RFC.md |
| Core API RFC | code/core/go/api/RFC.md |