--- module: forge.lthn.ai/core/go-proxy repo: core/go-proxy lang: go tier: lib depends: - code/core/go - code/core/go/api tags: - stratum - mining - proxy - nicehash - tcp - tls --- # 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 ```go // 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 ```go // 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, awaiting `login` request. 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. ```go // 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: 1. Reject if `params.login` is empty: `"Invalid payment address provided"`. 2. If `Config.AccessPassword != ""`, compare to `params.pass`; reject if mismatch: `"Invalid password"`. 3. Parse custom difficulty suffix: if `params.login` ends with `+{number}` (e.g. `WALLET+50000`), strip the suffix, set `miner.customDiff = number`. If no suffix and `Config.CustomDiff > 0`, set `miner.customDiff = Config.CustomDiff`. 4. Store `params.rigid` as `miner.rigID` if present. 5. Store `params.algo` list; set `miner.extAlgo = true` if list is non-empty. 6. Assign `miner.rpcID` (UUID v4). 7. Dispatch `LoginEvent`. 8. Transition to `WaitReady`. 9. 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`): 1. Validate `params.id == miner.rpcID`; reject otherwise: `"Unauthenticated"`. 2. Validate `params.job_id` is non-empty; reject otherwise: `"Missing job id"`. 3. Validate `params.nonce` is exactly 8 lowercase hex characters; reject otherwise: `"Invalid nonce"`. 4. Dispatch `SubmitEvent`. The splitter performs upstream forwarding. ### 6.3 Keepalived Handling On `keepalived` request arrival: 1. Reset `lastActivityAt` to now. 2. Reply `{"result": {"status": "KEEPALIVED"}}`. --- ## 7. Job Value Type ```go // 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 ```go // 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 ```go // 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 ```go // 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. ```go // 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() {} ``` ```go // 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 ```go // 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 ```go // 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 ```go // 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 ```go // 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). ```go // 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 ```go // 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 ```go // AccessLog writes connection lifecycle lines to an append-only text file. // // Line format (connect): 2026-04-04T12:00:00Z CONNECT // Line format (close): 2026-04-04T12:00:00Z CLOSE rx= tx= // // 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 ```go // ShareLog writes share result lines to an append-only text file. // // Line format (accept): 2026-04-04T12:00:00Z ACCEPT diff= latency=ms // Line format (reject): 2026-04-04T12:00:00Z REJECT reason="" // // 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 ```go // 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 ```json { "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 ```json { "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 ```json { "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 `"********"`. ```go // 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 ```go // 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() {} ``` ```go // 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 ```go // 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) ```json {"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) ```json {"id":2,"jsonrpc":"2.0","method":"submit","params":{"id":"SESSION-UUID","job_id":"JOBID","nonce":"deadbeef","result":"HASH64HEX","algo":"cn/r"}} ``` ### Miner → Proxy (keepalived) ```json {"id":3,"method":"keepalived","params":{"id":"SESSION-UUID"}} ``` ### Proxy → Miner (login success, with algo extension) ```json {"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) ```json {"jsonrpc":"2.0","method":"job","params":{"blob":"BLOB160HEX","job_id":"JOBID","target":"b88d0600","algo":"cn/r","id":"SESSION-UUID"}} ``` ### Proxy → Miner (submit success) ```json {"id":2,"jsonrpc":"2.0","error":null,"result":{"status":"OK"}} ``` ### Proxy → Miner (error) ```json {"id":1,"jsonrpc":"2.0","error":{"code":-1,"message":"Invalid payment address provided"}} ``` ### Proxy → Miner (keepalived reply) ```json {"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`** ```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`** ```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`** ```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`** ```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`** ```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`** ```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 ```go // 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`** ```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 ```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` |