diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25abd9b..99c0679 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,3 +67,21 @@ jobs: run: docker build -t nomp-e2e -f scripts/e2e/Dockerfile . - name: run e2e suite (all coins) run: docker run --rm nomp-e2e + + # Isolated engine-payout leg for a Zcash-family coin (Flux/ZelHash). Split from + # the main e2e job because fluxd needs ~775MB of zk params; keeping it separate + # means the main suite stays lean. Proves the equihash engine drives a live + # fluxd and the engine pool pays through the shared processor. + e2e-flux: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - name: free disk space + run: | + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/.ghcup /opt/hostedtoolcache/CodeQL + df -h / + - name: build flux e2e image + run: docker build -t nomp-e2e-flux -f scripts/e2e/Dockerfile.flux . + - name: run flux payout leg + run: docker run --rm nomp-e2e-flux diff --git a/TUTORIAL.md b/TUTORIAL.md index 4adcfa3..02019c8 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -109,6 +109,48 @@ Most GPU algorithms only have C implementations. Put the C source under from an `init()` behind a build tag, and build with `CGO_ENABLED=1 go build -tags ./cmd/nomp`. `neoscrypt` (`github.com/sparkspay/go-neoscrypt`) is a worked example. +## Payments (configurable reward scheme, fork-configurable) + +Set `disablePayment: false` and add a `payment` block to pay miners automatically. +When a share solves a block the current round is sealed; once the block's coinbase +reaches maturity its reward is attributed to miners per the selected `payMode` and +paid via `sendmany`. Balances below `minPayment` carry over. + +```json +"disablePayment": false, +"payment": { + "interval": 600, // seconds between payout runs + "minPayment": 0.05, // min coin owed before a miner is paid (else carried over) + "daemon": 0, // index into daemons[] used for wallet RPC + "payMode": "prop", // "prop" | "pplns" | "solo" | "pps" + "pplnsWindow": 0, // pplns look-back as total share difficulty; 0 = the block's round + "ppsRate": 0, // pps price per share difficulty unit (coin); required for pps + "magnitude": 0, // base units per coin (1e8); 0 = auto-detect + "minConfirmations": 100, // coinbase maturity before a reward is paid + "addressCheckMethod": "getaddressinfo", // or "validateaddress" on older forks + "sendManyDummy": "", // leading sendmany arg (Bitcoin Core wants "") + "omitSendManyDummy": false // true if the fork's sendmany drops the leading arg +} +``` + +**`payMode`** selects the reward scheme: +- **`prop`** (default) — split the block reward proportionally to the shares of + the round that found it. +- **`pplns`** — split proportionally to the last `pplnsWindow` difficulty of + shares across rounds (a sliding window; resists pool-hopping). `pplnsWindow: 0` + falls back to the block's own round. +- **`solo`** — the miner who found the block takes the whole reward. +- **`pps`** — pay a fixed `ppsRate` per share difficulty **immediately** (each run + credits shares since a cursor and pays); found blocks refill the pool wallet + instead of being distributed, so the **pool** carries the luck variance. + +The remaining knobs let one binary pay out across bitcoind-family **forks** whose +wallet RPC differs: coinbase maturity (`minConfirmations`), coin precision +(`magnitude`), the address-ownership check (`addressCheckMethod`), and the +`sendmany` shape (`sendManyDummy` / `omitSendManyDummy`). The payout wallet must +own `poolAddress` (verified at startup). Miners are paid to their worker name, so +they connect with their wallet address as the username. + ## Add a category-C coin (an engine) Category-C coins are already supported by pluggable engines — you don't write one, diff --git a/TUTORIAL_zh.md b/TUTORIAL_zh.md index 7dd5974..ce95a1a 100644 --- a/TUTORIAL_zh.md +++ b/TUTORIAL_zh.md @@ -136,6 +136,41 @@ go build ./cmd/nomp --- +## 3.5 付款(可配置奖励方案,可配置支持各币 fork) + +设 `disablePayment: false` 并加 `payment` 配置块即可自动给矿工付款。share 打出块时封存当前轮, +待该块 coinbase 成熟后按 `payMode` 选定的方案分账、经 `sendmany` 发出;不足 `minPayment` 的余额结转下轮。 + +```json +"disablePayment": false, +"payment": { + "interval": 600, // 每轮付款间隔(秒) + "minPayment": 0.05, // 达到多少币才付(否则结转) + "daemon": 0, // 用 daemons[] 中第几个做钱包 RPC + "payMode": "prop", // "prop" | "pplns" | "solo" | "pps" + "pplnsWindow": 0, // pplns 回看窗口(按 share 总难度);0 = 用该块所在轮 + "ppsRate": 0, // pps 每单位 share 难度的定价(币);pps 模式必填 + "magnitude": 0, // 每币的最小单位数(1e8);0 = 自动探测 + "minConfirmations": 100, // coinbase 成熟确认数(付款前需达到) + "addressCheckMethod": "getaddressinfo", // 老 fork 用 "validateaddress" + "sendManyDummy": "", // sendmany 的首个 dummy 参数(Bitcoin Core 用 "") + "omitSendManyDummy": false // 若 fork 的 sendmany 去掉了首参则设 true +} +``` + +**`payMode`** 选奖励方案: +- **`prop`**(默认)——按**出块那一轮**各矿工的 share 比例分; +- **`pplns`**——按跨轮的**最近 `pplnsWindow` 难度**的 share 滑动窗口比例分(抗跳池);`pplnsWindow: 0` 退化为该块所在轮; +- **`solo`**——打出块的矿工独得全部奖励; +- **`pps`**——按 `ppsRate` 对每个 share **即时**计费发放(每轮把游标之后的新 share 记账并发款),出块奖励用于回补矿池钱包、不再分账,**由矿池承担运气方差**。 + +其余开关让**一份二进制**适配各 bitcoind 系 fork 的钱包差异:成熟确认数 +(`minConfirmations`)、币精度(`magnitude`)、地址归属校验方法(`addressCheckMethod`)、 +`sendmany` 形态(`sendManyDummy` / `omitSendManyDummy`)。付款钱包必须拥有 `poolAddress` +(启动时校验);矿工按其 worker 名收款,故矿工连接时用户名填钱包地址。 + +--- + ## 4. 新增一个头哈希算法(A→B 类) 算法系统已重构为**可插拔注册表**(`algorithm/algorithm.go`)。你有两种方式加算法。 diff --git a/algorithm/lyra2_vector_test.go b/algorithm/lyra2_vector_test.go new file mode 100644 index 0000000..73715a5 --- /dev/null +++ b/algorithm/lyra2_vector_test.go @@ -0,0 +1,55 @@ +package algorithm + +import ( + "encoding/binary" + "encoding/hex" + "math/big" + "testing" + + "github.com/mining-pool/not-only-mining-pool/utils" +) + +// TestLyra2Rev2AgainstRealMonacoinBlock verifies the pool's Lyra2REv2 against a +// real accepted Monacoin block (2500000): its PoW hash MUST be <= the block +// target. If not, the library disagrees with monacoind. +func TestLyra2Rev2AgainstRealMonacoinBlock(t *testing.T) { + const ( + version = uint32(536870912) + prevDisplay = "20f986555ce18dc5c5de9a69faf6d646026d7ba8da30350e2a93ae1912bbe690" + merkleDisplay = "4dabf61d902f3ca4b22f227287b693914f6287957485b39d68379fdd9d01a241" + ntime = uint32(1637217290) + bits = uint32(0x1a067960) + nonce = uint32(3183243202) + ) + prev, _ := hex.DecodeString(prevDisplay) + merkle, _ := hex.DecodeString(merkleDisplay) + + hdr := make([]byte, 0, 80) + var b [4]byte + binary.LittleEndian.PutUint32(b[:], version) + hdr = append(hdr, b[:]...) + hdr = append(hdr, utils.ReverseBytes(prev)...) + hdr = append(hdr, utils.ReverseBytes(merkle)...) + binary.LittleEndian.PutUint32(b[:], ntime) + hdr = append(hdr, b[:]...) + binary.LittleEndian.PutUint32(b[:], bits) + hdr = append(hdr, b[:]...) + binary.LittleEndian.PutUint32(b[:], nonce) + hdr = append(hdr, b[:]...) + if len(hdr) != 80 { + t.Fatalf("header len %d", len(hdr)) + } + + pow := Lyra2Rev2Hash(hdr) + powBig := new(big.Int).SetBytes(utils.ReverseBytes(pow)) + + exp := bits >> 24 + target := new(big.Int).Lsh(big.NewInt(int64(bits&0xffffff)), uint(8*(exp-3))) + + t.Logf("pow(reversed) = %x", utils.ReverseBytes(pow)) + t.Logf("pow(as-is) = %x", pow) + t.Logf("target = %064x", target) + if powBig.Cmp(target) > 0 { + t.Errorf("POW > target — bitgoin/lyra2rev2 does NOT match monacoind (a real accepted block fails PoW)") + } +} diff --git a/config.example.json b/config.example.json index d2f0f80..f6d92f5 100644 --- a/config.example.json +++ b/config.example.json @@ -61,6 +61,20 @@ "host": "0.0.0.0", "port": 20000 }, + "disablePayment": false, + "payment": { + "interval": 600, + "minPayment": 0.05, + "daemon": 0, + "payMode": "prop", + "pplnsWindow": 0, + "ppsRate": 0, + "magnitude": 0, + "minConfirmations": 100, + "addressCheckMethod": "getaddressinfo", + "sendManyDummy": "", + "omitSendManyDummy": false + }, "storage": { "network": "tcp", "host": "127.0.0.1", diff --git a/config/daemon.go b/config/daemon.go index a1453af..d103168 100644 --- a/config/daemon.go +++ b/config/daemon.go @@ -19,7 +19,7 @@ type DaemonOptions struct { } func (d *DaemonOptions) String() string { - return d.User + ":" + d.Password + "@" + d.Host + strconv.FormatInt(int64(d.Port), 10) + return d.User + "@" + d.Host + ":" + strconv.FormatInt(int64(d.Port), 10) } func (d *DaemonOptions) URL() string { diff --git a/config/options.go b/config/options.go index c923e23..4c57bfd 100644 --- a/config/options.go +++ b/config/options.go @@ -22,13 +22,14 @@ type Options struct { EmitInvalidBlockHashes bool `json:"emitInvalidBlockHashes"` TCPProxyProtocol bool `json:"tcpProxyProtocol"` // http://www.haproxy.org/download/1.8/doc/proxy-protocol.txt - API *APIOptions `json:"api"` - Banning *BanningOptions `json:"banning"` - Ports map[int]*PortOptions `json:"ports"` - Daemons []*DaemonOptions `json:"daemons"` - P2P *P2POptions `json:"p2p"` - Storage *RedisOptions `json:"storage"` - Algorithm *AlgorithmOptions `json:"algorithm"` + API *APIOptions `json:"api"` + Banning *BanningOptions `json:"banning"` + Ports map[int]*PortOptions `json:"ports"` + Daemons []*DaemonOptions `json:"daemons"` + P2P *P2POptions `json:"p2p"` + Storage *RedisOptions `json:"storage"` + Algorithm *AlgorithmOptions `json:"algorithm"` + PaymentOptions *PaymentOptions `json:"payment"` } func (o *Options) TotalFeePercent() float64 { diff --git a/config/payment.go b/config/payment.go index 6d9250c..5121a84 100644 --- a/config/payment.go +++ b/config/payment.go @@ -37,3 +37,76 @@ func (r *Recipient) GetScript() []byte { return r.script } + +type PaymentOptions struct { + Interval int64 `json:"interval"` // seconds between payout runs (default 600) + MinPayment float64 `json:"minPayment"` // minimum coin owed before a miner is paid + + Daemon int `json:"daemon"` // index into daemons[] used for wallet RPC (default 0) + + // PayMode selects how miners are rewarded: + // "prop" (default) proportional to that block's round shares + // "pplns" proportional to the last-N-difficulty window of shares + // "solo" the miner who found the block takes the whole reward + // "pps" pay a fixed rate per share immediately; found blocks + // just refill the pool wallet (the pool bears variance) + PayMode string `json:"payMode"` + // PPLNSWindow is the pplns look-back as a total share difficulty; <=0 falls + // back to the block's own round (so pplns degrades to prop). + PPLNSWindow float64 `json:"pplnsWindow"` + // PPSRate is the pps price paid per unit of share difficulty (in coin). It + // must be set (>0) when payMode is "pps". + PPSRate float64 `json:"ppsRate"` + + // --- coin-fork configurability --- + + // Magnitude is the number of base units (satoshis) in one coin, e.g. 1e8 for + // Bitcoin. 0 auto-detects from the wallet's getbalance precision. + Magnitude float64 `json:"magnitude"` + + // MinConfirmations is coinbase maturity: a found block's reward is only paid + // once its generation transaction has at least this many confirmations + // (default 100; some forks use 30/40/240). + MinConfirmations int64 `json:"minConfirmations"` + + // AddressCheckMethod is the wallet RPC used to verify the pool owns its + // payout address: "getaddressinfo" (Bitcoin Core 0.18+, default) or + // "validateaddress" (older cores / forks that kept it). + AddressCheckMethod string `json:"addressCheckMethod"` + + // SendManyDummy is the leading dummy/account argument to sendmany. Bitcoin + // Core requires it and keeps it as "" (the default). + SendManyDummy string `json:"sendManyDummy"` + // OmitSendManyDummy drops the leading argument entirely for forks whose + // sendmany takes only the {address:amount} map. + OmitSendManyDummy bool `json:"omitSendManyDummy"` +} + +// WithDefaults returns a copy with unset fork knobs filled with the Bitcoin +// Core defaults, so a minimal config still works on mainstream forks. +func (o *PaymentOptions) WithDefaults() *PaymentOptions { + c := *o + if c.Interval <= 0 { + c.Interval = 600 + } + if c.MinConfirmations <= 0 { + c.MinConfirmations = 100 + } + if c.AddressCheckMethod == "" { + c.AddressCheckMethod = "getaddressinfo" + } + if c.PayMode == "" { + c.PayMode = PayModeProp + } + c.PayMode = strings.ToLower(c.PayMode) + // SendManyDummy defaults to "" (its zero value), which is what Bitcoin Core wants. + return &c +} + +// Reward schemes selectable via PayMode. +const ( + PayModeProp = "prop" + PayModePPLNS = "pplns" + PayModeSolo = "solo" + PayModePPS = "pps" +) diff --git a/daemons/getbalance.go b/daemons/getbalance.go index 6fdca57..cf1300a 100644 --- a/daemons/getbalance.go +++ b/daemons/getbalance.go @@ -1,3 +1,18 @@ package daemons +import ( + "encoding/json" + "fmt" +) + type GetBalance float64 + +func BytesToGetBalance(b []byte) (GetBalance, error) { + var getBalance GetBalance + err := json.Unmarshal(b, &getBalance) + if err != nil { + return 0.0, fmt.Errorf("unmashal getBalance response %s failed with error %s", b, err) + } + + return getBalance, nil +} diff --git a/daemons/getblocktemplate.go b/daemons/getblocktemplate.go index 171b8f7..8ac4783 100644 --- a/daemons/getblocktemplate.go +++ b/daemons/getblocktemplate.go @@ -100,6 +100,9 @@ func (dm *DaemonManager) GetBlockTemplate() (getBlockTemplate *GetBlockTemplate, instance, result, _ := dm.Cmd("getblocktemplate", []interface{}{map[string]interface{}{"capabilities": []string{"coinbasetxn", "workid", "coinbase/append"}, "rules": rules}}, ) + if err != nil { + return nil, err + } if result.Error != nil { return nil, errors.New(fmt.Sprint("getblocktemplate call failed for daemon instance ", instance, " with error ", result.Error)) diff --git a/daemons/gettransaction.go b/daemons/gettransaction.go new file mode 100644 index 0000000..4b8f63a --- /dev/null +++ b/daemons/gettransaction.go @@ -0,0 +1,41 @@ +package daemons + +import ( + "encoding/json" + "fmt" +) + +type GetTransaction struct { + Amount float64 `json:"amount"` + Confirmations int `json:"confirmations"` + Generated bool `json:"generated"` + Blockhash string `json:"blockhash"` + Blockheight int `json:"blockheight"` + Blockindex int `json:"blockindex"` + Blocktime int `json:"blocktime"` + Txid string `json:"txid"` + Walletconflicts []interface{} `json:"walletconflicts"` + Time int `json:"time"` + Timereceived int `json:"timereceived"` + Bip125Replaceable string `json:"bip125-replaceable"` + Details []GetTransactionDetail `json:"details"` + Hex string `json:"hex"` +} + +type GetTransactionDetail struct { + Address string `json:"address"` + Category string `json:"category"` + Amount float64 `json:"amount"` + Label string `json:"label"` + Vout int `json:"vout"` +} + +func BytesToGetTransaction(b []byte) (*GetTransaction, error) { + var getTransaction GetTransaction + err := json.Unmarshal(b, &getTransaction) + if err != nil { + return nil, fmt.Errorf("getTransaction call failed with error %s", err) + } + + return &getTransaction, nil +} diff --git a/daemons/manager.go b/daemons/manager.go index 6fa1467..f3a9070 100644 --- a/daemons/manager.go +++ b/daemons/manager.go @@ -176,6 +176,31 @@ func (dm *DaemonManager) CmdAll(method string, params []interface{}) (responses return responses, results } +// CheckBlockAccepted asks every daemon for the block and reports whether they +// all have it (accepted into the chain) along with its coinbase transaction id +// (the block's first tx), which the payout processor needs to attribute the +// reward. tx is empty when no daemon could return the block. +func (dm *DaemonManager) CheckBlockAccepted(blockHash string) (isAccepted bool, tx string) { + _, results := dm.CmdAll("getblock", []interface{}{blockHash}) + + isAccepted = true + for i := range results { + isAccepted = isAccepted && results[i] != nil && results[i].Error == nil + } + + for i := range results { + if results[i] == nil { + continue + } + gb := BytesToGetBlock(results[i].Result) + if gb.Tx != nil { + return isAccepted, gb.Tx[0] + } + } + + return isAccepted, "" +} + func (dm *DaemonManager) CheckStatusCode(statusCode int) error { switch statusCode / 100 { case 2: @@ -206,6 +231,26 @@ func (dm *DaemonManager) CheckStatusCode(statusCode int) error { // Cmd will call daemons one by one and return the first answer // one by one not all is to try fetching from the same one not random one func (dm *DaemonManager) Cmd(method string, params []interface{}) (*config.DaemonOptions, *JsonRpcResponse, *http.Response) { + for i := range dm.Daemons { + return dm.cmdToIndex(i, method, params) + } + + log.Error("failed getting GBT from all daemons!") + return nil, nil, nil +} + +// CmdToDaemon runs method against a specific daemon by index. The payer uses it +// so wallet RPCs (getaddressinfo/getbalance/gettransaction/sendmany) reach the +// configured payment.daemon rather than always the first daemon. +func (dm *DaemonManager) CmdToDaemon(index int, method string, params []interface{}) (*config.DaemonOptions, *JsonRpcResponse, *http.Response) { + if index < 0 || index >= len(dm.Daemons) { + log.Errorf("daemon index %d out of range (have %d daemons)", index, len(dm.Daemons)) + return nil, nil, nil + } + return dm.cmdToIndex(index, method, params) +} + +func (dm *DaemonManager) cmdToIndex(i int, method string, params []interface{}) (*config.DaemonOptions, *JsonRpcResponse, *http.Response) { reqRawData, err := json.Marshal(map[string]interface{}{ "id": utils.RandPositiveInt64(), "method": method, @@ -213,35 +258,30 @@ func (dm *DaemonManager) Cmd(method string, params []interface{}) (*config.Daemo }) if err != nil { log.Error(err) + return dm.Daemons[i], nil, nil } - var result JsonRpcResponse - var res *http.Response - for i := range dm.Daemons { - var err error - res, err = dm.DoHttpRequest(dm.Daemons[i], reqRawData) - if err != nil { - log.Error(err) - } - - //if err := dm.CheckStatusCode(res.StatusCode); err != nil { - // log.Println(err) - //} - - raw, err := ioutil.ReadAll(res.Body) - if err != nil { - log.Error(err) - } - res.Body = ioutil.NopCloser(bytes.NewBuffer(raw)) + // A nil result signals failure to the caller. Never fall through a transport, + // read or decode error: an unreachable daemon returns a nil response (so + // res.Body would panic) and a garbled body must not look like a success. + res, err := dm.DoHttpRequest(dm.Daemons[i], reqRawData) + if err != nil || res == nil { + log.Error("daemon request failed: ", err) + return dm.Daemons[i], nil, nil + } - err = json.Unmarshal(raw, &result) - if err != nil { - log.Error(err) - } + raw, err := ioutil.ReadAll(res.Body) + if err != nil { + log.Error(err) + return dm.Daemons[i], nil, res + } + res.Body = ioutil.NopCloser(bytes.NewBuffer(raw)) - return dm.Daemons[i], &result, res + var result JsonRpcResponse + if err := json.Unmarshal(raw, &result); err != nil { + log.Error(err) + return dm.Daemons[i], nil, res } - log.Error("failed getting GBT from all daemons!") - return nil, nil, nil + return dm.Daemons[i], &result, res } diff --git a/daemons/sendmany.go b/daemons/sendmany.go new file mode 100644 index 0000000..5566080 --- /dev/null +++ b/daemons/sendmany.go @@ -0,0 +1,18 @@ +package daemons + +import ( + "encoding/json" + "fmt" +) + +type SendMany string // receive the raw tx hex string + +func BytesToSendMany(b []byte) (*SendMany, error) { + var sendMany SendMany + err := json.Unmarshal(b, &sendMany) + if err != nil { + return nil, fmt.Errorf("getTransaction call failed with error %s", err) + } + + return &sendMany, nil +} diff --git a/daemons/validateaddress.go b/daemons/validateaddress.go index b598959..2e0132e 100644 --- a/daemons/validateaddress.go +++ b/daemons/validateaddress.go @@ -5,11 +5,12 @@ import ( "fmt" ) +// DEPRECATION WARNING: Parts of this command have been deprecated and moved to getaddressinfo. type ValidateAddress struct { Isvalid bool `json:"isvalid"` Address string `json:"address"` ScriptPubKey string `json:"scriptPubKey"` - Ismine bool `json:"ismine"` + IsMine bool `json:"ismine"` Iswatchonly bool `json:"iswatchonly"` Isscript bool `json:"isscript"` Iswitness bool `json:"iswitness"` @@ -31,18 +32,17 @@ type ValidateAddress struct { Hdkeypath string `json:"hdkeypath"` Hdseedid string `json:"hdseedid"` Hdmasterkeyid string `json:"hdmasterkeyid"` - Labels []struct { - Name string `json:"name"` - Purpose string `json:"purpose"` - } `json:"labels"` + // Bitcoin Core changed "labels" from an array of {name,purpose} objects to an + // array of plain strings; accept either so getaddressinfo parses on any fork. + Labels []interface{} `json:"labels"` } -func BytesToValidateAddress(b []byte) *ValidateAddress { +func BytesToValidateAddress(b []byte) (*ValidateAddress, error) { var validateAddress ValidateAddress err := json.Unmarshal(b, &validateAddress) if err != nil { - log.Fatal(fmt.Sprint("validateAddress call failed with error ", err)) + return nil, fmt.Errorf("unmashal validateAddress response %s failed with error %s", b, err) } - return &validateAddress + return &validateAddress, nil } diff --git a/docs/E2E.md b/docs/E2E.md index abf4d08..1eaf596 100644 --- a/docs/E2E.md +++ b/docs/E2E.md @@ -78,9 +78,13 @@ core-geth) must be on `PATH` too. Then run `scripts/e2e/run-all.sh`. (`--mine --miner.threads 0`, remote sealer only, so only the pool can seal), monerod regtest (mints a valid pay-to address via `monero-wallet-rpc`), kaspad simnet (`kaspawallet` address, `KASPA_ALLOW_UNSYNCED=1`). -- **`run-all.sh`** — orchestrates all 10 coins (6 GBT + 4 engine) and prints a - scoreboard; exits non-zero if any coin fails (with `E2E_STRICT=1`, a skipped - coin also fails). +- **`payment.sh`** — payout processor E2E on bitcoind regtest: run a + payment-enabled pool, mine a block to the pool address, mature the coinbase + (+100 blocks), and assert the pool actually pays the miner via `sendmany` + (checks the redis `payouts` hash and the on-chain receipt). +- **`run-all.sh`** — orchestrates all coins (6 GBT + 4 engine) plus the payout + scenario and prints a scoreboard; exits non-zero if any leg fails (with + `E2E_STRICT=1`, a skipped leg also fails). ## GitHub Actions diff --git a/docs/PLUGGABLE_ENGINES_zh.md b/docs/PLUGGABLE_ENGINES_zh.md index 39435ae..36ae064 100644 --- a/docs/PLUGGABLE_ENGINES_zh.md +++ b/docs/PLUGGABLE_ENGINES_zh.md @@ -134,8 +134,17 @@ 路由层已用假引擎单测覆盖(`stratum/engine_test.go`):login 推 work(含端口默认难度)、getWork、 valid/invalid submit 回包、未授权拒绝。 -**遗留 TODO**:引擎模式的 share 尚未写入 redis(不影响出块,仅影响统计/支付), -见 `stratum/engine.go` 中的 TODO 标注。 +**share 落库**:引擎模式的有效/无效 share 现已写入 redis(`stratum/engine.go` +submit 处理里的 `go sc.DB.PutShare(...)`),复用 GBT 的 `PutShare`——统计(算力、 +有效/无效计数)、按 `miner.rig` 拆分、轮次贡献与 PPLNS 日志均可用,且按**分配难度** +计量(非达成难度)。 + +**派奖**:bitcoin-family 的引擎币(如 Ravencoin/kawpow)已打通自动派奖—— +kawpow 出块时经 `dm.CheckBlockAccepted` 解析 coinbase txid 填入 `share.TxHash`, +`PutShare` 据此封轮并记 pending block,`NewEnginePool` 在 `disablePayment:false` +时启动复用的 PROP/PPLNS/SOLO/PPS payer。非 bitcoin 系引擎(ethash/randomx/kaspa) +的 share 不带 coinbase txid,只落库计量、不入 pending,其派奖需各自接钱包模型 +(在这些币上开启 payment 会在 `PaymentManager.Init` 处 fail-fast)。 > 后续仍建议做"Step 0 重构"(抽 `gbt.Engine`),让两条通路都走 `engine.Engine`,彻底解耦。 diff --git a/engine/equihash/equihash.go b/engine/equihash/equihash.go index 380578a..93dd381 100644 --- a/engine/equihash/equihash.go +++ b/engine/equihash/equihash.go @@ -419,7 +419,12 @@ func (e *Engine) OnSubmit(s engine.Session, params []interface{}) *types.Share { e.dm.SubmitBlock(blockHex) share.BlockHex = blockHex share.BlockHash = hex.EncodeToString(utils.ReverseBytes(utils.Sha256d(full))) - log.Warn("equihash block candidate at height ", j.height, " hash ", share.BlockHash) + // Zcash-family coins are bitcoin-family (t-address) wallets, so resolve the + // coinbase txid the payout processor needs (empty if the node rejected it). + if _, tx := e.dm.CheckBlockAccepted(share.BlockHash); tx != "" { + share.TxHash = tx + } + log.Warn("equihash block candidate at height ", j.height, " hash ", share.BlockHash, " coinbase ", share.TxHash) } return share diff --git a/engine/kawpow/kawpow.go b/engine/kawpow/kawpow.go index afd95ca..3b2fb96 100644 --- a/engine/kawpow/kawpow.go +++ b/engine/kawpow/kawpow.go @@ -323,8 +323,18 @@ func (e *Engine) OnSubmit(s engine.Session, params []interface{}) *types.Share { blockHex := hex.EncodeToString(j.serializeBlock(nonce, mix)) e.dm.SubmitBlock(blockHex) share.BlockHex = blockHex - share.BlockHash = hex.EncodeToString(utils.ReverseBytes(utils.Sha256d(j.fullHeader(nonce, mix)))) - log.Warn("kawpow block candidate at height ", j.height, " hash ", share.BlockHash) + // A KAWPOW block's id is its progpow final hash (Ravencoin CBlockHeader:: + // GetHash → KAWPOWHash_OnlyMix), NOT sha256d of the header — the digest the + // PoW already produced. Using sha256d here yields a hash the node doesn't + // know, so getblock/CheckBlockAccepted can't resolve the coinbase txid. + share.BlockHash = hex.EncodeToString(digest) + // Ravencoin is bitcoin-family, so resolve the coinbase txid the payout + // processor needs to attribute this block's reward (empty if the node + // rejected it — the share stays a normal contribution then). + if _, tx := e.dm.CheckBlockAccepted(share.BlockHash); tx != "" { + share.TxHash = tx + } + log.Warn("kawpow block candidate at height ", j.height, " hash ", share.BlockHash, " coinbase ", share.TxHash) } return share diff --git a/go.mod b/go.mod index 139a26e..a52c8be 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23 toolchain go1.24.1 require ( + github.com/alicebob/miniredis/v2 v2.38.0 github.com/bitgoin/lyra2rev2 v0.0.0-20161212102046-bae9ad2043bb github.com/c0mm4nd/go-bech32 v0.0.0-20201015031713-6bb434e0ac5d github.com/etclabscore/go-etchash v0.0.0-20220831225151-7746dfe207b3 @@ -40,6 +41,7 @@ require ( github.com/klauspost/cpuid/v2 v2.0.9 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/stretchr/testify v1.9.0 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect go.uber.org/atomic v1.6.0 // indirect go.uber.org/multierr v1.5.0 // indirect go.uber.org/zap v1.14.1 // indirect diff --git a/go.sum b/go.sum index 2636cc8..1f1306f 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/aead/skein v0.0.0-20160722084837-9365ae6e95d2 h1:q5TSngwXJdajCyZPQR+e github.com/aead/skein v0.0.0-20160722084837-9365ae6e95d2/go.mod h1:4JBZEId5BaLqvA2DGU53phvwkn2WpeLhNSF79/uKBPs= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= +github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= github.com/aristanetworks/goarista v0.0.0-20170210015632-ea17b1a17847/go.mod h1:D/tb0zPVXnP7fmsLZjtdUhSsumbK/ij54UXjjVgMGxQ= github.com/aws/aws-sdk-go v1.25.48/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= @@ -225,6 +227,8 @@ github.com/tyler-smith/go-bip39 v1.0.1-0.20181017060643-dbb3b84ba2ef/go.mod h1:s github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/wsddn/go-ecdh v0.0.0-20161211032359-48726bab9208/go.mod h1:IotVbo4F+mw0EzQ08zFqg7pK3FebNXpaMsRy2RT+Ees= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= diff --git a/jobs/jobManager.go b/jobs/jobManager.go index b664433..85e217a 100644 --- a/jobs/jobManager.go +++ b/jobs/jobManager.go @@ -81,7 +81,7 @@ func (jm *JobManager) ProcessShare(share *types.Share) { log.Info("submitting new Block: ", share.BlockHex) jm.DaemonManager.SubmitBlock(share.BlockHex) - isAccepted, tx = jm.CheckBlockAccepted(share.BlockHash) + isAccepted, tx = jm.DaemonManager.CheckBlockAccepted(share.BlockHash) share.TxHash = tx if isAccepted { log.Info("Block ", share.BlockHash, " Accepted! generation tx: ", share.TxHash, ". Wait for pendding!") @@ -94,33 +94,13 @@ func (jm *JobManager) ProcessShare(share *types.Share) { jm.ProcessTemplate(gbt) } - // notValidBlock but isValidShare - go jm.Storage.PutShare(share, isAccepted) + // notValidBlock but isValidShare. PutShare enqueues to a single ordered writer + // (no goroutine here), so a block's round seal is ordered relative to the + // shares it should contain. + jm.Storage.PutShare(share, isAccepted) } -func (jm *JobManager) CheckBlockAccepted(blockHash string) (isAccepted bool, tx string) { - _, results := jm.DaemonManager.CmdAll("getblock", []interface{}{blockHash}) - - isAccepted = true - for i := range results { - isAccepted = isAccepted && results[i] != nil && results[i].Error == nil - } - - for i := range results { - if results[i] == nil { - continue - } - - gb := daemons.BytesToGetBlock(results[i].Result) - if gb.Tx != nil { - return isAccepted, gb.Tx[0] - } - } - - return isAccepted, "" -} - // UpdateCurrentJob updates the job when mining the same height but tx changes func (jm *JobManager) UpdateCurrentJob(rpcData *daemons.GetBlockTemplate) { tmpBlockTemplate := NewJob( @@ -289,7 +269,11 @@ func (jm *JobManager) ProcessSubmit(jobId string, prevDiff, diff *big.Float, ext new(big.Float).SetInt(new(big.Int).Mul(algorithm.MaxTargetTruncated, big.NewInt(1< 0 { @@ -315,7 +299,7 @@ func (jm *JobManager) ProcessSubmit(jobId string, prevDiff, diff *big.Float, ext BlockHeight: job.GetBlockTemplate.Height, BlockReward: job.GetBlockTemplate.CoinbaseValue, - Diff: shareDiff, + Diff: assignedDiff, BlockHash: blockHash, BlockHex: blockHex, } @@ -325,6 +309,7 @@ func (jm *JobManager) ProcessSubmit(jobId string, prevDiff, diff *big.Float, ext if new(big.Float).Quo(bigShareDiff, diff).Cmp(big.NewFloat(0.99)) < 0 { // Check if share matched a previous difficulty from before a vardiff retarget if prevDiff != nil && bigShareDiff.Cmp(prevDiff) >= 0 { + prevAssigned, _ := prevDiff.Float64() // credited at the pre-retarget difficulty it satisfied return &types.Share{ JobId: jobId, RemoteAddr: ipAddr, @@ -333,7 +318,7 @@ func (jm *JobManager) ProcessSubmit(jobId string, prevDiff, diff *big.Float, ext BlockHeight: job.GetBlockTemplate.Height, BlockReward: job.GetBlockTemplate.CoinbaseValue, - Diff: shareDiff, + Diff: prevAssigned, } } else { return &types.Share{ @@ -353,7 +338,7 @@ func (jm *JobManager) ProcessSubmit(jobId string, prevDiff, diff *big.Float, ext Miner: miner, Rig: rig, - Diff: shareDiff, + Diff: assignedDiff, } } diff --git a/payments/payer.go b/payments/payer.go index f5c984d..92c73eb 100644 --- a/payments/payer.go +++ b/payments/payer.go @@ -1,65 +1,559 @@ package payments import ( + "encoding/json" + "fmt" + "math" + "time" + logging "github.com/ipfs/go-log/v2" + + "github.com/mining-pool/not-only-mining-pool/config" "github.com/mining-pool/not-only-mining-pool/daemons" + "github.com/mining-pool/not-only-mining-pool/storage" ) var log = logging.Logger("payments") -type PayMode int +// PaymentManager pays miners proportionally to the shares they contributed to +// each block round (PROP). It is fork-configurable via config.PaymentOptions so +// one binary pays out on any bitcoind-family fork whose wallet RPC differs. +type PaymentManager struct { + options *config.PaymentOptions + dm *daemons.DaemonManager + db *storage.DB -const ( - PayOnManual PayMode = iota - PayPPLNS // calc rewards on block found - PayPPS // calc rewards every day -) + PoolAddress *config.Recipient + Magnitude float64 // base units (satoshis) per coin + MinPayment uint64 // satoshis -type PaymentManager struct { - pay chan struct{} - dm *daemons.DaemonManager + validAddr map[string]bool // cache of address-ownership/validity checks +} + +func NewPaymentManager(options *config.PaymentOptions, poolAddr *config.Recipient, dm *daemons.DaemonManager, db *storage.DB) *PaymentManager { + pm := &PaymentManager{ + dm: dm, + db: db, + PoolAddress: poolAddr, + validAddr: map[string]bool{}, + } + // options is nil when payments are disabled; Init/Serve are then never called. + if options != nil { + pm.options = options.WithDefaults() + } + return pm +} + +// cmd routes a wallet RPC to the configured payment daemon (payment.daemon), +// which may differ from the mining daemon used for getblocktemplate/submitblock. +func (pm *PaymentManager) cmd(method string, params []interface{}) (*config.DaemonOptions, *daemons.JsonRpcResponse) { + instance, result, _ := pm.dm.CmdToDaemon(pm.options.Daemon, method, params) + return instance, result +} + +// Init validates the pay mode, the pool address ownership, and the coin +// precision. It must be called (once) before Serve, only when payments are on. +func (pm *PaymentManager) Init() error { + switch pm.options.PayMode { + case config.PayModeProp, config.PayModePPLNS, config.PayModeSolo: + case config.PayModePPS: + if pm.options.PPSRate <= 0 { + return fmt.Errorf("payMode %q requires a positive ppsRate", config.PayModePPS) + } + default: + return fmt.Errorf("unsupported payMode %q (want prop, pplns, solo or pps)", pm.options.PayMode) + } + if err := pm.validatePoolAddress(); err != nil { + return err + } + // In PPS the share log must retain uncredited shares (trimmed by the cursor, + // not rank-capped), or a burst between runs would drop unpaid work. + pm.db.SetPPSMode(pm.options.PayMode == config.PayModePPS) + return pm.setMagnitude() +} + +// attribute splits a mature block's reward across miners per the configured +// payMode, returning miner -> reward (satoshis). An empty result means the block +// cannot be attributed (no shares / unknown finder) and should be orphaned. +func (pm *PaymentManager) attribute(pb *storage.PendingBlock, rewardSat uint64) (map[string]uint64, error) { + switch pm.options.PayMode { + case config.PayModeSolo: + if pb.Finder == "" { + return nil, nil + } + return map[string]uint64{pb.Finder: rewardSat}, nil + case config.PayModePPLNS: + if pm.options.PPLNSWindow <= 0 { + // documented fallback: a non-positive window pays the block's own round + // (GetPPLNSShares would otherwise treat <=0 as an unbounded window and + // pay miners from earlier rounds too). + shares, err := pm.db.GetRoundContrib(pb.Height) + if err != nil { + return nil, err + } + return splitByShares(rewardSat, shares), nil + } + shares, err := pm.db.GetPPLNSShares(pb.Mark, pm.options.PPLNSWindow) + if err != nil { + return nil, err + } + return splitByShares(rewardSat, shares), nil + default: // prop + shares, err := pm.db.GetRoundContrib(pb.Height) + if err != nil { + return nil, err + } + return splitByShares(rewardSat, shares), nil + } } -func NewPaymentManager(mode PayMode, dm *daemons.DaemonManager) *PaymentManager { - return &PaymentManager{ - pay: make(chan struct{}), - dm: dm, +// splitByShares divides rewardSat proportionally to each miner's share weight. +func splitByShares(rewardSat uint64, shares map[string]float64) map[string]uint64 { + var total float64 + for _, s := range shares { + total += s + } + if total <= 0 { + return nil } + out := make(map[string]uint64, len(shares)) + for miner, s := range shares { + out[miner] = uint64(math.Floor(float64(rewardSat) * (s / total))) + } + return out } func (pm *PaymentManager) Serve() { - func() { - for { - <-pm.pay - pm.doPay() - } - }() -} - -func (pm *PaymentManager) doPay() { - // PPLNS solution: - // - // for range // not wg => keep balance safe - // validateaddress - // getbalance - // ['hgetall', coin + ':balances'], - // ['smembers', coin + ':blocksPending'] - // - // { - // blockHash: details[0], - // txHash: details[1], - // height: details[2], - // }; - // - // gettransaction generatiion tx & getaccount pooladdress -> chech tx detail -> kick/orphan/confirm - // - // ['hgetall', coin + ':shares:round' + r.height] - // var reward = parseInt(round.reward * magnitude); - // - // sendmany [addressAccount, addressAmounts] - // - // ['hincrbyfloat', coin + ':balances', w, satoshisToCoins(worker.balanceChange)] - // ['hincrbyfloat', coin + ':payouts', w, worker.sent] - // - // smove => move block from pending to kick/orphan/confirm + ticker := time.NewTicker(time.Duration(pm.options.Interval) * time.Second) + defer ticker.Stop() + for range ticker.C { + if err := pm.processPayments(); err != nil { + log.Error("payment run failed: ", err) + } + } +} + +// validatePoolAddress confirms the payment wallet owns the pool payout address, +// so its coinbase rewards land somewhere the pool can spend from. +func (pm *PaymentManager) validatePoolAddress() error { + instance, result := pm.cmd(pm.options.AddressCheckMethod, []interface{}{pm.PoolAddress.Address}) + if result == nil { + return fmt.Errorf("no response from payment daemon on %s", pm.options.AddressCheckMethod) + } + if result.Error != nil { + return fmt.Errorf("payment daemon %s: %s", instance.String(), result.Error.Message) + } + + va, err := daemons.BytesToValidateAddress(result.Result) + if err != nil { + return err + } + if !va.IsMine { + return fmt.Errorf("payment daemon %s does not own pool address %s; payouts impossible", instance.String(), pm.PoolAddress.Address) + } + return nil +} + +// validRecipient reports whether addr is a payable address, caching the result +// since addresses rarely change. An unpayable worker name is skipped (its +// balance carries over) so it cannot fail the whole sendmany batch. +func (pm *PaymentManager) validRecipient(addr string) bool { + if v, ok := pm.validAddr[addr]; ok { + return v + } + _, result := pm.cmd(pm.options.AddressCheckMethod, []interface{}{addr}) + // getaddressinfo rejects a malformed address with an error; validateaddress + // instead returns isvalid=false without an error, so check both. + ok := result != nil && result.Error == nil + if ok && pm.options.AddressCheckMethod == "validateaddress" { + if va, err := daemons.BytesToValidateAddress(result.Result); err != nil || !va.Isvalid { + ok = false + } + } + if !ok { + log.Warnf("skipping payouts to unpayable address %q; balance will carry over", addr) + } + pm.validAddr[addr] = ok + return ok +} + +// setMagnitude fixes the base-units-per-coin factor. It honours an explicit +// config value and otherwise uses the bitcoin-family standard of 1e8 (satoshis). +// It deliberately does not infer precision from a getbalance string: JSON numbers +// drop trailing zeros, so a "12.34" balance would wrongly imply a magnitude of 100. +func (pm *PaymentManager) setMagnitude() error { + if pm.options.Magnitude > 0 { + pm.Magnitude = pm.options.Magnitude + } else { + pm.Magnitude = 1e8 // satoshis; set payment.magnitude for coins with other precision + } + pm.MinPayment = pm.CoinToSat(pm.options.MinPayment) + log.Infof("payments: magnitude=%.0f min=%d sat interval=%ds maturity=%d", pm.Magnitude, pm.MinPayment, pm.options.Interval, pm.options.MinConfirmations) + return nil +} + +// worker accumulates one miner's owed amount across a payout run. +type worker struct { + Address string + Balance uint64 // carried-over unpaid balance (satoshis) + Reward uint64 // rewards earned this run (satoshis) + Sent uint64 // paid out this run (satoshis) +} + +// matureBlock is a pending block whose reward is confirmed and ready to split. +type matureBlock struct { + block *storage.PendingBlock + reward uint64 // satoshis credited to the pool address +} + +// processPayments runs one payout cycle: classify pending blocks by their +// coinbase transaction (orphan / immature / mature), split each mature block's +// reward across that round's shares, and pay every miner over the threshold — +// persisting balances, payouts and block state only if sendmany succeeds. +// reconcile resolves any payout intent left by an interrupted run BEFORE new work +// is processed, so the same blocks/shares can't be paid twice. sendmany and the +// redis state update can't share a transaction: settle persists the intended +// update before broadcasting and stamps the txid right after, and this closes the +// gap on the next run. +func (pm *PaymentManager) reconcile() error { + update, txid, exists, err := pm.db.GetPayoutIntent() + if err != nil { + return err + } + if !exists { + return nil + } + if txid != "" { + // The sendmany broadcast (we have its txid) but ApplyPayments was interrupted + // — finish it now (atomic, and it clears the intent). No re-send. + log.Warnf("resuming interrupted payout run (txid %s)", txid) + return pm.db.ApplyPayments(update) + } + // No txid: a sendmany may have broadcast in the instant before the crash. Halt + // rather than risk double-paying; an operator confirms against the wallet and + // clears the payouts:intent key to resume. + return fmt.Errorf("in-flight payout intent has no txid — a sendmany may have broadcast (paid=%v); "+ + "verify the wallet, then delete the payouts:intent key to resume", update.Paid) +} + +func (pm *PaymentManager) processPayments() error { + if err := pm.reconcile(); err != nil { + return err + } + if pm.options.PayMode == config.PayModePPS { + return pm.processPPS() + } + + pendingBlocks, err := pm.db.GetAllPendingBlocks() + if err != nil { + return err + } + if len(pendingBlocks) == 0 { + return nil + } + + update := &storage.PaymentUpdate{Balances: map[string]float64{}, Paid: map[string]float64{}} + var matured []matureBlock + + // seed workers from carried-over balances + balances, err := pm.db.GetAllMinerBalances() + if err != nil { + return err + } + workers := make(map[string]*worker, len(balances)) + for miner, bal := range balances { + workers[miner] = &worker{Address: miner, Balance: pm.CoinToSat(bal)} + } + + for _, pb := range pendingBlocks { + reward, category, confirmations, ok := pm.classifyBlock(pb) + if !ok { + continue // transient RPC problem; retry this block next run + } + switch { + case category == string(storage.Orphan) || category == string(storage.Kicked): + update.Orphaned = append(update.Orphaned, pb.String()) + update.DeleteRounds = append(update.DeleteRounds, pb.Height) + case confirmations < pm.options.MinConfirmations: + // fewer than minConfirmations — leave it pending, revisit next run. An + // "immature" coinbase is just a young one; sendmany funds payouts from + // any mature wallet UTXO, so minConfirmations alone governs distribution. + default: // enough confirmations — distribute + rewardSat := pm.CoinToSat(reward) + dist, err := pm.attribute(pb, rewardSat) + if err != nil { + return err + } + if len(dist) == 0 { + // matured but nothing to attribute (no shares / unknown finder): + // orphan it so we don't leak the reward or loop forever. + update.Orphaned = append(update.Orphaned, pb.String()) + update.DeleteRounds = append(update.DeleteRounds, pb.Height) + continue + } + for miner, r := range dist { + w := workers[miner] + if w == nil { + w = &worker{Address: miner} + workers[miner] = w + } + w.Reward += r + } + matured = append(matured, matureBlock{block: pb, reward: rewardSat}) + } + } + + if len(matured) == 0 { + // nothing to pay this run; still persist orphan moves so we don't rescan them. + if len(update.Orphaned) == 0 { + return nil + } + return pm.db.ApplyPayments(update) + } + + // Build the COMPLETE update (including the blocks being confirmed) before + // settle, so the intent it persists before broadcasting is whole. + for _, mb := range matured { + update.Confirmed = append(update.Confirmed, mb.block.String()) + update.DeleteRounds = append(update.DeleteRounds, mb.block.Height) + } + if err := pm.settle(workers, update, 0); err != nil { + return err // sendmany failed — persist nothing, blocks stay pending for retry + } + return pm.db.ApplyPayments(update) +} + +// processPPS pays a fixed rate per share: it credits every share logged since +// the cursor (reward = difficulty * ppsRate), pays balances over the threshold, +// and confirms matured blocks — whose rewards refill the pool wallet rather than +// being distributed (the pool, not the miners, carries the luck variance). +func (pm *PaymentManager) processPPS() error { + cursor, err := pm.db.GetPPSCursor() + if err != nil { + return err + } + newShares, maxSeq, err := pm.db.GetSharesSince(cursor) + if err != nil { + return err + } + + balances, err := pm.db.GetAllMinerBalances() + if err != nil { + return err + } + workers := make(map[string]*worker, len(balances)) + for miner, bal := range balances { + workers[miner] = &worker{Address: miner, Balance: pm.CoinToSat(bal)} + } + for miner, diff := range newShares { + w := workers[miner] + if w == nil { + w = &worker{Address: miner} + workers[miner] = w + } + w.Reward += pm.CoinToSat(diff * pm.options.PPSRate) + } + + update := &storage.PaymentUpdate{Balances: map[string]float64{}, Paid: map[string]float64{}, PPSCursor: maxSeq} + + // Move matured blocks out of pending (their reward funds the wallet, so it is + // not split); orphan the rest. Nothing here is distributed to miners. + pendingBlocks, err := pm.db.GetAllPendingBlocks() + if err != nil { + return err + } + for _, pb := range pendingBlocks { + _, category, confirmations, ok := pm.classifyBlock(pb) + if !ok { + continue + } + switch { + case category == string(storage.Orphan) || category == string(storage.Kicked): + update.Orphaned = append(update.Orphaned, pb.String()) + update.DeleteRounds = append(update.DeleteRounds, pb.Height) + case confirmations < pm.options.MinConfirmations: + // fewer than minConfirmations — leave pending until mature + default: + update.Confirmed = append(update.Confirmed, pb.String()) + update.DeleteRounds = append(update.DeleteRounds, pb.Height) + } + } + + if err := pm.settle(workers, update, 0); err != nil { + return err // payout failed — leave the cursor/blocks so we retry next run + } + return pm.db.ApplyPayments(update) +} + +// classifyBlock looks up a pending block's coinbase transaction and reports the +// reward credited to the pool address, its category and its confirmations. ok is +// false only on a transient RPC failure (retry next run). +func (pm *PaymentManager) classifyBlock(pb *storage.PendingBlock) (reward float64, category string, confirmations int64, ok bool) { + _, result := pm.cmd("gettransaction", []interface{}{pb.TxHash}) + if result == nil { + return 0, "", 0, false + } + if result.Error != nil { + if result.Error.Code == -5 { // wallet doesn't know the coinbase tx + // Don't orphan on a bare -5: a syncing/rescanning payment wallet can + // transiently miss a valid block's tx. Confirm the block is really + // off-chain before orphaning it (irreversible). + return pm.classifyMissingTx(pb) + } + log.Warnf("gettransaction %s: %s", pb.TxHash, result.Error.Message) + return 0, "", 0, false + } + + gt, err := daemons.BytesToGetTransaction(result.Result) + if err != nil { + log.Error(err) + return 0, "", 0, false + } + // A reorged coinbase stays in the wallet but goes conflicted (negative + // confirmations); orphan it so its round is released. + if gt.Confirmations < 0 { + return 0, string(storage.Orphan), 0, true + } + + // find the detail crediting the pool address (the coinbase output). + category = string(storage.Generate) + reward = gt.Amount + for i := range gt.Details { + if gt.Details[i].Address == pm.PoolAddress.Address { + category = gt.Details[i].Category + reward = gt.Details[i].Amount + break + } + } + return reward, category, int64(gt.Confirmations), true +} + +// classifyMissingTx decides orphan vs transient miss when the wallet doesn't know +// a pending block's coinbase tx: it checks whether the block itself is on-chain, +// so a resyncing wallet doesn't cause a valid block to be orphaned. +func (pm *PaymentManager) classifyMissingTx(pb *storage.PendingBlock) (reward float64, category string, confirmations int64, ok bool) { + _, result := pm.cmd("getblock", []interface{}{pb.Hash}) + if result == nil { + return 0, "", 0, false // transient RPC — retry, don't orphan + } + if result.Error != nil { + // the node doesn't have this block at all — it genuinely never made it in. + return 0, string(storage.Orphan), 0, true + } + var gb struct { + Confirmations int64 `json:"confirmations"` + } + if err := json.Unmarshal(result.Result, &gb); err != nil { + return 0, "", 0, false + } + if gb.Confirmations < 0 { + return 0, string(storage.Orphan), 0, true // block is on a side chain — orphaned + } + // On the main chain but the wallet doesn't know its coinbase yet (syncing / + // rescanning, or the wallet doesn't own the pool address). Retry, don't orphan. + log.Warnf("block %s is on-chain but its coinbase %s is not in the payment wallet; leaving pending (is the wallet synced and owning the pool address?)", pb.Hash, pb.TxHash) + return 0, "", 0, false +} + +// settle computes each worker's payout and, if anything is owed, persists a +// durable intent, broadcasts sendmany, and stamps the returned txid — filling the +// update with the resulting balances/payouts. On -6 (insufficient funds for fees) +// it retries withholding a little more so miners cover the tx fee. +// +// The intent is written BEFORE the broadcast and the txid stamped immediately +// AFTER, so a crash between sendmany and ApplyPayments is resolved by reconcile() +// instead of paying the same round twice. +func (pm *PaymentManager) settle(workers map[string]*worker, update *storage.PaymentUpdate, withhold float64) error { + amounts := map[string]float64{} + for _, w := range workers { + owed := w.Balance + w.Reward + toSend := uint64(math.Floor(float64(owed) * (1 - withhold))) + // Worker names are unauthenticated (AuthorizeFn accepts any name), so a + // single malformed one must not fail the whole sendmany batch: skip it and + // carry its balance forward instead of poisoning everyone's payout. + if toSend >= pm.MinPayment && toSend > 0 && pm.validRecipient(w.Address) { + amounts[w.Address] = pm.SatToCoin(toSend) + w.Sent = toSend + } else { + w.Sent = 0 + } + } + + // Record the resulting balances/payouts in the update up front so the intent + // persisted before broadcasting is complete (re-filled on each withhold retry). + for _, w := range workers { + update.Balances[w.Address] = pm.SatToCoin((w.Balance + w.Reward) - w.Sent) + if w.Sent > 0 { + update.Paid[w.Address] = pm.SatToCoin(w.Sent) + } else { + delete(update.Paid, w.Address) + } + } + + if len(amounts) == 0 { + return nil // nothing to pay this run; caller still applies block/round moves + } + + // Persist the intent BEFORE broadcasting. + if err := pm.db.PutPayoutIntent(update); err != nil { + return err + } + + args := []interface{}{} + if !pm.options.OmitSendManyDummy { + args = append(args, pm.options.SendManyDummy) + } + args = append(args, amounts) + + _, result := pm.cmd("sendmany", args) + if result == nil { + // No response — the request may or may not have broadcast. Leave the intent + // (txid empty) so the next run halts for review rather than risk re-paying. + return fmt.Errorf("no response from payment daemon on sendmany (payout intent left for review)") + } + if result.Error != nil { + if result.Error.Code == -6 { // node rejected before broadcast: safe to retry + // Geometric back-off: a gentle 1% first step covers the common case + // (only the tx fee is missing), then double so a large shortfall + // converges in a bounded number of retries instead of ~100. + next := withhold * 2 + if next < 0.01 { + next = 0.01 + } + if next >= 1 { + _ = pm.db.DelPayoutIntent() + return fmt.Errorf("wallet cannot cover sendmany fees even at 100%% withholding") + } + log.Warnf("insufficient funds for fees; retrying with %.0f%% withheld", next*100) + return pm.settle(workers, update, next) + } + // Any other error means the node rejected it (no broadcast) — clear the intent. + _ = pm.db.DelPayoutIntent() + return fmt.Errorf("sendmany failed: %s", result.Error.Message) + } + + // Broadcast succeeded — stamp the txid at once so a crash before ApplyPayments + // is recoverable without re-sending. + var txid string + _ = json.Unmarshal(result.Result, &txid) + if err := pm.db.SetPayoutIntentTxid(txid); err != nil { + return err + } + if withhold > 0 { + log.Warnf("paid %d workers (txid %s; withheld %.0f%% for fees)", len(amounts), txid, withhold*100) + } else { + log.Infof("paid %d workers (txid %s)", len(amounts), txid) + } + return nil +} + +func (pm *PaymentManager) SatToCoin(sat uint64) float64 { + return float64(sat) / pm.Magnitude +} + +func (pm *PaymentManager) CoinToSat(coin float64) uint64 { + if coin <= 0 { + return 0 + } + return uint64(math.Floor(coin*pm.Magnitude + 0.5)) } diff --git a/payments/payer_test.go b/payments/payer_test.go new file mode 100644 index 0000000..105d7eb --- /dev/null +++ b/payments/payer_test.go @@ -0,0 +1,701 @@ +package payments + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "sync" + "testing" + + "github.com/alicebob/miniredis/v2" + + "github.com/mining-pool/not-only-mining-pool/config" + "github.com/mining-pool/not-only-mining-pool/daemons" + "github.com/mining-pool/not-only-mining-pool/storage" +) + +const poolAddr = "mPoolPayoutAddressXXXXXXXXXXXXXXXXX" + +// fakeWallet is a minimal bitcoind-style JSON-RPC wallet. Tests override the +// gettransaction/sendmany behaviour; getaddressinfo/getbalance have defaults. +type fakeWallet struct { + mu sync.Mutex + balance string // raw getbalance result + sendmany func(dummyPresent bool, amounts map[string]float64) (string, *daemons.JsonRpcError) + gettx func(txid string) (*daemons.GetTransaction, *daemons.JsonRpcError) + getblk func(hash string) (confirmations int64, err *daemons.JsonRpcError) // getblock; nil = block not found + + SentBatches []map[string]float64 // captured sendmany calls + AddrMethod string // captured address-check method actually called + badAddr map[string]bool // addresses the wallet rejects as unpayable +} + +func (w *fakeWallet) handler(rw http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req struct { + Id interface{} `json:"id"` + Method string `json:"method"` + Params []interface{} `json:"params"` + } + _ = json.Unmarshal(body, &req) + + var result interface{} + var rpcErr *daemons.JsonRpcError + + switch req.Method { + case "getaddressinfo", "validateaddress": + w.mu.Lock() + w.AddrMethod = req.Method + bad := w.badAddr[req.Params[0].(string)] + w.mu.Unlock() + if bad { + // getaddressinfo rejects a malformed address with an error. + rpcErr = &daemons.JsonRpcError{Code: -5, Message: "Invalid address"} + break + } + result = map[string]interface{}{"ismine": true, "isvalid": true, "address": req.Params[0], "labels": []string{""}} + case "getbalance": + // getbalance returns a bare number; emit it raw. + writeRPC(rw, req.Id, json.RawMessage(w.balance), nil) + return + case "gettransaction": + gt, e := w.gettx(req.Params[0].(string)) + result, rpcErr = gt, e + case "getblock": + if w.getblk == nil { // default: node doesn't have the block → orphan + rpcErr = &daemons.JsonRpcError{Code: -5, Message: "Block not found"} + break + } + conf, e := w.getblk(req.Params[0].(string)) + if e != nil { + rpcErr = e + break + } + result = map[string]interface{}{"hash": req.Params[0], "confirmations": conf} + case "sendmany": + dummyPresent := false + var amounts map[string]float64 + if len(req.Params) == 2 { // [dummy, {addr:amt}] + dummyPresent = true + amounts = toAmounts(req.Params[1]) + } else if len(req.Params) == 1 { // [{addr:amt}] + amounts = toAmounts(req.Params[0]) + } + w.mu.Lock() + w.SentBatches = append(w.SentBatches, amounts) + w.mu.Unlock() + txid, e := w.sendmany(dummyPresent, amounts) + result, rpcErr = txid, e + default: + rpcErr = &daemons.JsonRpcError{Code: -32601, Message: "method not found: " + req.Method} + } + + raw, _ := json.Marshal(result) + writeRPC(rw, req.Id, raw, rpcErr) +} + +func toAmounts(v interface{}) map[string]float64 { + out := map[string]float64{} + if m, ok := v.(map[string]interface{}); ok { + for k, val := range m { + if f, ok := val.(float64); ok { + out[k] = f + } + } + } + return out +} + +func writeRPC(rw http.ResponseWriter, id interface{}, result json.RawMessage, rpcErr *daemons.JsonRpcError) { + resp := map[string]interface{}{"id": id, "result": result} + if rpcErr != nil { + resp["result"] = nil + resp["error"] = rpcErr + } + _ = json.NewEncoder(rw).Encode(resp) +} + +// harness wires a PaymentManager to miniredis + the fakeWallet. +type harness struct { + pm *PaymentManager + db *storage.DB + wallet *fakeWallet + mr *miniredis.Miniredis +} + +func newHarness(t *testing.T, opts *config.PaymentOptions) *harness { + t.Helper() + mr, err := miniredis.Run() + if err != nil { + t.Fatal(err) + } + t.Cleanup(mr.Close) + + host, portStr := splitHostPort(mr.Addr()) + port, _ := strconv.Atoi(portStr) + db := storage.NewStorage("TEST", &config.RedisOptions{Network: "tcp", Host: host, Port: port}) + + w := &fakeWallet{balance: "12.34567890"} + srv := httptest.NewServer(http.HandlerFunc(w.handler)) + t.Cleanup(srv.Close) + + u, _ := url.Parse(srv.URL) + dport, _ := strconv.Atoi(u.Port()) + dm := daemons.NewDaemonManager([]*config.DaemonOptions{{Host: u.Hostname(), Port: dport}}, &config.CoinOptions{Name: "TEST"}) + + pm := NewPaymentManager(opts, &config.Recipient{Address: poolAddr, Type: "p2pkh"}, dm, db) + return &harness{pm: pm, db: db, wallet: w, mr: mr} +} + +func splitHostPort(addr string) (host, port string) { + for i := len(addr) - 1; i >= 0; i-- { + if addr[i] == ':' { + return addr[:i], addr[i+1:] + } + } + return addr, "" +} + +// seedRoundShares writes a sealed round's per-miner contribution. +func (h *harness) seedRoundShares(height uint64, shares map[string]float64) { + for miner, diff := range shares { + h.mr.HSet("TEST:shares:round"+strconv.FormatUint(height, 10), miner, strconv.FormatFloat(diff, 'f', -1, 64)) + } +} + +// seedPending records a pending block (with optional finder / pplns mark). +func (h *harness) seedPending(height uint64, txHash, finder string, mark int64) { + pb := &storage.PendingBlock{Hash: "blk" + strconv.FormatUint(height, 10), TxHash: txHash, Height: height, Finder: finder, Mark: mark} + h.mr.SAdd("TEST:blocks:pending", pb.String()) +} + +// seedRound seals a round + its pending block, as PutShare would (prop mode). +func (h *harness) seedRound(height uint64, txHash string, shares map[string]float64) { + h.seedRoundShares(height, shares) + h.seedPending(height, txHash, "", 0) +} + +// seedPPLNS appends one share to the pplns log at a monotonic sequence. +func (h *harness) seedPPLNS(miner string, diff float64, seq int64) { + h.mr.ZAdd("TEST:shares:pplnslog", float64(seq), + miner+":"+strconv.FormatFloat(diff, 'f', -1, 64)+":"+strconv.FormatInt(seq, 10)) +} + +func generateTx(confirmations int, reward float64) func(string) (*daemons.GetTransaction, *daemons.JsonRpcError) { + return func(txid string) (*daemons.GetTransaction, *daemons.JsonRpcError) { + return &daemons.GetTransaction{ + Amount: reward, + Confirmations: confirmations, + Generated: true, + Details: []daemons.GetTransactionDetail{{Address: poolAddr, Category: "generate", Amount: reward}}, + }, nil + } +} + +func TestPayout_FullFlow(t *testing.T) { + h := newHarness(t, &config.PaymentOptions{Interval: 1, MinPayment: 0, MinConfirmations: 100}) + if err := h.pm.Init(); err != nil { + t.Fatal(err) + } + // round 100: A contributed 30% of shares, B 70%; block reward 50 coin. + h.seedRound(100, "tx100", map[string]float64{"minerA": 30, "minerB": 70}) + h.wallet.gettx = generateTx(120, 50.0) // mature + h.wallet.sendmany = func(dummy bool, amounts map[string]float64) (string, *daemons.JsonRpcError) { + if !dummy { + t.Errorf("expected the default sendmany dummy arg to be present") + } + return "payouttxid", nil + } + + if err := h.pm.processPayments(); err != nil { + t.Fatal(err) + } + + // exactly one sendmany, split 15 / 35. + if len(h.wallet.SentBatches) != 1 { + t.Fatalf("want 1 sendmany, got %d", len(h.wallet.SentBatches)) + } + sent := h.wallet.SentBatches[0] + if got := sent["minerA"]; got != 15.0 { + t.Errorf("minerA payout = %v, want 15", got) + } + if got := sent["minerB"]; got != 35.0 { + t.Errorf("minerB payout = %v, want 35", got) + } + + // payouts recorded, balances zeroed (fully paid), block confirmed, round gone. + if v := h.mr.HGet("TEST:payouts", "minerA"); v != "15" { + t.Errorf("payouts minerA = %q, want 15", v) + } + if v := h.mr.HGet("TEST:balances", "minerB"); v != "0" { + t.Errorf("balance minerB = %q, want 0", v) + } + if ok, _ := h.mr.SIsMember("TEST:blocks:confirmed", (&storage.PendingBlock{Hash: "blk100", TxHash: "tx100", Height: 100}).String()); !ok { + t.Error("block 100 was not moved to confirmed") + } + if h.mr.Exists("TEST:shares:round100") { + t.Error("sealed round 100 should be deleted after payout") + } +} + +// --- double-spend guard (durable payout intent) --- + +// A successful run leaves no lingering intent. +func TestPayout_IntentClearedOnSuccess(t *testing.T) { + h := newHarness(t, &config.PaymentOptions{MinPayment: 0, MinConfirmations: 100}) + if err := h.pm.Init(); err != nil { + t.Fatal(err) + } + h.seedRound(100, "tx100", map[string]float64{"minerA": 100}) + h.wallet.gettx = generateTx(120, 50.0) + h.wallet.sendmany = func(bool, map[string]float64) (string, *daemons.JsonRpcError) { return "txid", nil } + if err := h.pm.processPayments(); err != nil { + t.Fatal(err) + } + if h.mr.Exists("TEST:payouts:intent") { + t.Error("payout intent should be cleared after a successful run") + } +} + +// If a run broadcast sendmany (intent has a txid) but died before ApplyPayments, +// the next run FINISHES it — it must not sendmany again, and the block/payout +// land exactly once. +func TestPayout_ResumesSentIntentWithoutRepaying(t *testing.T) { + h := newHarness(t, &config.PaymentOptions{MinPayment: 0, MinConfirmations: 100}) + if err := h.pm.Init(); err != nil { + t.Fatal(err) + } + // A block is still pending, but the interrupted run already paid minerA (txid + // stamped) — its intent confirms the block and records the payout. + h.seedPending(100, "tx100", "", 0) + pb := (&storage.PendingBlock{Hash: "blk100", TxHash: "tx100", Height: 100}).String() + up := &storage.PaymentUpdate{ + Balances: map[string]float64{"minerA": 0}, + Paid: map[string]float64{"minerA": 50}, + Confirmed: []string{pb}, + DeleteRounds: []uint64{100}, + } + if err := h.db.PutPayoutIntent(up); err != nil { + t.Fatal(err) + } + if err := h.db.SetPayoutIntentTxid("alreadysent"); err != nil { + t.Fatal(err) + } + h.wallet.sendmany = func(bool, map[string]float64) (string, *daemons.JsonRpcError) { + t.Fatal("resuming a sent intent must NOT broadcast sendmany again") + return "", nil + } + if err := h.pm.processPayments(); err != nil { + t.Fatal(err) + } + if v := h.mr.HGet("TEST:payouts", "minerA"); v != "50" { + t.Errorf("resumed payout = %q, want 50 (exactly once)", v) + } + if ok, _ := h.mr.SIsMember("TEST:blocks:confirmed", pb); !ok { + t.Error("resumed intent should confirm the block") + } + if h.mr.Exists("TEST:payouts:intent") { + t.Error("intent should be cleared after resume") + } +} + +// If a run died mid-sendmany (intent present, NO txid), we can't tell whether the +// broadcast happened — halt for review rather than risk double-paying. +func TestPayout_HaltsOnAmbiguousIntent(t *testing.T) { + h := newHarness(t, &config.PaymentOptions{MinPayment: 0, MinConfirmations: 100}) + if err := h.pm.Init(); err != nil { + t.Fatal(err) + } + h.seedRound(100, "tx100", map[string]float64{"minerA": 100}) + h.db.PutPayoutIntent(&storage.PaymentUpdate{Paid: map[string]float64{"minerA": 50}}) // no txid + h.wallet.sendmany = func(bool, map[string]float64) (string, *daemons.JsonRpcError) { + t.Fatal("must not pay while an ambiguous intent is unresolved") + return "", nil + } + if err := h.pm.processPayments(); err == nil { + t.Error("processPayments must halt (error) on an intent with no txid") + } + if !h.mr.Exists("TEST:payouts:intent") { + t.Error("ambiguous intent must be preserved for operator review, not cleared") + } +} + +func TestPayout_ImmatureStaysPending(t *testing.T) { + h := newHarness(t, &config.PaymentOptions{MinPayment: 0, MinConfirmations: 100}) + _ = h.pm.Init() + h.seedRound(101, "tx101", map[string]float64{"minerA": 100}) + h.wallet.gettx = generateTx(10, 50.0) // only 10 confs < 100 + h.wallet.sendmany = func(bool, map[string]float64) (string, *daemons.JsonRpcError) { + t.Fatal("must not pay an immature block") + return "", nil + } + if err := h.pm.processPayments(); err != nil { + t.Fatal(err) + } + if ok, _ := h.mr.SIsMember("TEST:blocks:pending", (&storage.PendingBlock{Hash: "blk101", TxHash: "tx101", Height: 101}).String()); !ok { + t.Error("immature block must remain pending") + } +} + +func TestPayout_OrphanNotPaid(t *testing.T) { + h := newHarness(t, &config.PaymentOptions{MinPayment: 0, MinConfirmations: 100}) + _ = h.pm.Init() + h.seedRound(102, "tx102", map[string]float64{"minerA": 100}) + h.wallet.gettx = func(string) (*daemons.GetTransaction, *daemons.JsonRpcError) { + return nil, &daemons.JsonRpcError{Code: -5, Message: "Invalid or non-wallet transaction id"} + } + h.wallet.sendmany = func(bool, map[string]float64) (string, *daemons.JsonRpcError) { + t.Fatal("must not pay an orphaned block") + return "", nil + } + if err := h.pm.processPayments(); err != nil { + t.Fatal(err) + } + if ok, _ := h.mr.SIsMember("TEST:blocks:orphaned", (&storage.PendingBlock{Hash: "blk102", TxHash: "tx102", Height: 102}).String()); !ok { + t.Error("orphaned block must move to orphaned set") + } + if h.mr.Exists("TEST:shares:round102") { + t.Error("orphaned round shares should be cleared") + } +} + +// A -5 (wallet doesn't know the coinbase) must NOT orphan a block that is still +// on-chain — a resyncing wallet is transient; leave it pending and retry. +func TestPayout_MissingTxButOnChainStaysPending(t *testing.T) { + h := newHarness(t, &config.PaymentOptions{MinPayment: 0, MinConfirmations: 100}) + _ = h.pm.Init() + h.seedRound(102, "tx102", map[string]float64{"minerA": 100}) + h.wallet.gettx = func(string) (*daemons.GetTransaction, *daemons.JsonRpcError) { + return nil, &daemons.JsonRpcError{Code: -5, Message: "Invalid or non-wallet transaction id"} + } + h.wallet.getblk = func(string) (int64, *daemons.JsonRpcError) { return 30, nil } // block is on-chain + h.wallet.sendmany = func(bool, map[string]float64) (string, *daemons.JsonRpcError) { + t.Fatal("must not pay") + return "", nil + } + if err := h.pm.processPayments(); err != nil { + t.Fatal(err) + } + pb := (&storage.PendingBlock{Hash: "blk102", TxHash: "tx102", Height: 102}).String() + if ok, _ := h.mr.SIsMember("TEST:blocks:orphaned", pb); ok { + t.Error("on-chain block must NOT be orphaned on a transient wallet -5") + } + if ok, _ := h.mr.SIsMember("TEST:blocks:pending", pb); !ok { + t.Error("block should remain pending for retry") + } + if !h.mr.Exists("TEST:shares:round102") { + t.Error("round shares must be preserved while the block is pending") + } +} + +// A reorged coinbase (negative confirmations from gettransaction) is orphaned. +func TestPayout_ReorgedCoinbaseOrphaned(t *testing.T) { + h := newHarness(t, &config.PaymentOptions{MinPayment: 0, MinConfirmations: 100}) + _ = h.pm.Init() + h.seedRound(102, "tx102", map[string]float64{"minerA": 100}) + h.wallet.gettx = func(string) (*daemons.GetTransaction, *daemons.JsonRpcError) { + return &daemons.GetTransaction{Amount: 50, Confirmations: -3, Generated: true}, nil + } + h.wallet.sendmany = func(bool, map[string]float64) (string, *daemons.JsonRpcError) { + t.Fatal("must not pay a reorged block") + return "", nil + } + if err := h.pm.processPayments(); err != nil { + t.Fatal(err) + } + pb := (&storage.PendingBlock{Hash: "blk102", TxHash: "tx102", Height: 102}).String() + if ok, _ := h.mr.SIsMember("TEST:blocks:orphaned", pb); !ok { + t.Error("reorged (conflicted) block must be orphaned") + } +} + +func TestPayout_BelowMinPaymentCarriesOver(t *testing.T) { + // min payment 10 coin; A earns 15 (paid), B earns 5 (carried over as balance). + h := newHarness(t, &config.PaymentOptions{MinPayment: 10, MinConfirmations: 100}) + _ = h.pm.Init() + h.seedRound(103, "tx103", map[string]float64{"minerA": 75, "minerB": 25}) + h.wallet.gettx = generateTx(120, 20.0) // reward 20 -> A=15, B=5 + h.wallet.sendmany = func(_ bool, amounts map[string]float64) (string, *daemons.JsonRpcError) { + if _, ok := amounts["minerB"]; ok { + t.Errorf("minerB below min payment must not be sent") + } + return "txid", nil + } + if err := h.pm.processPayments(); err != nil { + t.Fatal(err) + } + if v := h.mr.HGet("TEST:balances", "minerB"); v != "5" { + t.Errorf("minerB carried balance = %q, want 5", v) + } + if v := h.mr.HGet("TEST:payouts", "minerA"); v != "15" { + t.Errorf("minerA payout = %q, want 15", v) + } +} + +// --- pay modes --- + +func TestPayout_Solo(t *testing.T) { + h := newHarness(t, &config.PaymentOptions{MinPayment: 0, MinConfirmations: 100, PayMode: "solo"}) + if err := h.pm.Init(); err != nil { + t.Fatal(err) + } + // round shares exist for other miners, but solo pays only the block finder. + h.seedRoundShares(300, map[string]float64{"minerA": 90, "minerB": 10}) + h.seedPending(300, "tx300", "minerFinder", 0) + h.wallet.gettx = generateTx(120, 50.0) + h.wallet.sendmany = func(_ bool, amounts map[string]float64) (string, *daemons.JsonRpcError) { + if amounts["minerFinder"] != 50.0 { + t.Errorf("solo finder payout = %v, want 50", amounts["minerFinder"]) + } + if _, ok := amounts["minerA"]; ok { + t.Errorf("solo must not pay non-finders") + } + return "txid", nil + } + if err := h.pm.processPayments(); err != nil { + t.Fatal(err) + } + if v := h.mr.HGet("TEST:payouts", "minerFinder"); v != "50" { + t.Errorf("finder payout = %q, want 50", v) + } +} + +func TestPayout_PPLNS(t *testing.T) { + h := newHarness(t, &config.PaymentOptions{MinPayment: 0, MinConfirmations: 100, PayMode: "pplns", PPLNSWindow: 40}) + if err := h.pm.Init(); err != nil { + t.Fatal(err) + } + // pplns log (seq → miner:diff): A older, then B, then C. Window=40 looks back + // from the block mark (seq 6): seq 6,5 (C,C) + 4,3 (B,B) = 40 → {B:20,C:20}; + // minerA's older shares fall outside the window and get nothing. + for _, e := range []struct { + miner string + seq int64 + }{{"minerA", 1}, {"minerA", 2}, {"minerB", 3}, {"minerB", 4}, {"minerC", 5}, {"minerC", 6}} { + h.seedPPLNS(e.miner, 10, e.seq) + } + h.seedPending(301, "tx301", "minerC", 6) // block found at seq 6 + h.wallet.gettx = generateTx(120, 40.0) // reward 40 -> B:20, C:20 + h.wallet.sendmany = func(_ bool, amounts map[string]float64) (string, *daemons.JsonRpcError) { + if amounts["minerB"] != 20.0 || amounts["minerC"] != 20.0 { + t.Errorf("pplns window split wrong: %v", amounts) + } + if _, ok := amounts["minerA"]; ok { + t.Errorf("minerA is outside the pplns window and must not be paid") + } + return "txid", nil + } + if err := h.pm.processPayments(); err != nil { + t.Fatal(err) + } +} + +func TestPayout_PPS(t *testing.T) { + // ppsRate 0.5 coin per diff unit: A(10 diff)->5, B(20 diff)->10. The found + // block's 50-coin reward funds the wallet and is NOT distributed. + h := newHarness(t, &config.PaymentOptions{MinPayment: 0, MinConfirmations: 100, PayMode: "pps", PPSRate: 0.5}) + if err := h.pm.Init(); err != nil { + t.Fatal(err) + } + h.seedPPLNS("minerA", 10, 1) + h.seedPPLNS("minerB", 20, 2) + h.seedPending(400, "tx400", "minerB", 2) + h.wallet.gettx = generateTx(120, 50.0) // mature -> block gets confirmed + h.wallet.sendmany = func(_ bool, amounts map[string]float64) (string, *daemons.JsonRpcError) { + if amounts["minerA"] != 5.0 || amounts["minerB"] != 10.0 { + t.Errorf("pps payout wrong (want A=5 B=10): %v", amounts) + } + return "txid", nil + } + if err := h.pm.processPayments(); err != nil { + t.Fatal(err) + } + if v := h.mr.HGet("TEST:payouts", "minerB"); v != "10" { + t.Errorf("pps payout minerB = %q, want 10", v) + } + // block confirmed (reward funds the wallet, not split) and cursor advanced. + if ok, _ := h.mr.SIsMember("TEST:blocks:confirmed", (&storage.PendingBlock{Hash: "blk400", TxHash: "tx400", Height: 400, Finder: "minerB", Mark: 2}).String()); !ok { + t.Error("matured block should be confirmed in pps mode") + } + if got, _ := h.mr.Get("TEST:pps:cursor"); got != "2" { + t.Errorf("pps cursor = %q, want 2", got) + } + // re-run: no new shares -> no further payout. + h.wallet.SentBatches = nil + if err := h.pm.processPayments(); err != nil { + t.Fatal(err) + } + for _, b := range h.wallet.SentBatches { + if len(b) > 0 { + t.Errorf("pps re-run must not pay already-credited shares: %v", b) + } + } +} + +func TestPPS_RequiresRate(t *testing.T) { + h := newHarness(t, &config.PaymentOptions{PayMode: "pps"}) // ppsRate defaults to 0 + if err := h.pm.Init(); err == nil { + t.Error("payMode pps without ppsRate must fail Init") + } +} + +// --- fork configurability --- + +func TestForkConfig_ValidateAddressAndNoDummy(t *testing.T) { + h := newHarness(t, &config.PaymentOptions{ + MinPayment: 0, + MinConfirmations: 40, // e.g. a fork with 40-block maturity + AddressCheckMethod: "validateaddress", + OmitSendManyDummy: true, // fork whose sendmany drops the leading arg + }) + if err := h.pm.Init(); err != nil { + t.Fatal(err) + } + if h.wallet.AddrMethod != "validateaddress" { + t.Errorf("address check used %q, want validateaddress", h.wallet.AddrMethod) + } + h.seedRound(200, "tx200", map[string]float64{"minerA": 100}) + h.wallet.gettx = generateTx(50, 10.0) // 50 >= 40 maturity -> mature + h.wallet.sendmany = func(dummy bool, _ map[string]float64) (string, *daemons.JsonRpcError) { + if dummy { + t.Errorf("SendManyDummy=nil should omit the leading arg") + } + return "txid", nil + } + if err := h.pm.processPayments(); err != nil { + t.Fatal(err) + } + if len(h.wallet.SentBatches) != 1 { + t.Fatalf("expected a payout, got %d sendmany calls", len(h.wallet.SentBatches)) + } +} + +func TestPaymentOptionsDefaults(t *testing.T) { + got := (&config.PaymentOptions{}).WithDefaults() + if got.Interval != 600 || got.MinConfirmations != 100 || got.AddressCheckMethod != "getaddressinfo" || got.PayMode != "prop" { + t.Errorf("unexpected defaults: %+v", got) + } + if got.SendManyDummy != "" || got.OmitSendManyDummy { + t.Errorf("sendmany dummy default should be present-empty, got %q omit=%v", got.SendManyDummy, got.OmitSendManyDummy) + } +} + +// A zero/negative pplnsWindow is documented to fall back to the block's round, +// not pay every retained share across earlier rounds. +func TestPayout_PPLNSZeroWindowFallsBackToRound(t *testing.T) { + h := newHarness(t, &config.PaymentOptions{MinPayment: 0, MinConfirmations: 100, PayMode: "pplns", PPLNSWindow: 0}) + if err := h.pm.Init(); err != nil { + t.Fatal(err) + } + // round 500 contains only minerA; the pplns log additionally holds minerB from + // an earlier round. With window 0 we must pay the round (A only), not the log. + h.seedRoundShares(500, map[string]float64{"minerA": 100}) + h.seedPPLNS("minerA", 10, 1) + h.seedPPLNS("minerB", 10, 2) + h.seedPending(500, "tx500", "minerA", 2) // mark past both log entries + h.wallet.gettx = generateTx(120, 50.0) + h.wallet.sendmany = func(_ bool, amounts map[string]float64) (string, *daemons.JsonRpcError) { + if amounts["minerA"] != 50.0 { + t.Errorf("minerA payout = %v, want the full 50 from its round", amounts["minerA"]) + } + if _, ok := amounts["minerB"]; ok { + t.Errorf("minerB is from an earlier round and must not be paid: %v", amounts) + } + return "txid", nil + } + if err := h.pm.processPayments(); err != nil { + t.Fatal(err) + } +} + +// One unpayable worker name must not fail the whole batch: it is skipped and its +// balance carries over while everyone else is still paid. +func TestPayout_InvalidAddressSkipped(t *testing.T) { + h := newHarness(t, &config.PaymentOptions{MinPayment: 0, MinConfirmations: 100}) + h.wallet.badAddr = map[string]bool{"minerBad": true} + if err := h.pm.Init(); err != nil { + t.Fatal(err) + } + h.seedRound(600, "tx600", map[string]float64{"minerGood": 30, "minerBad": 70}) + h.wallet.gettx = generateTx(120, 100.0) // good->30, bad->70 + h.wallet.sendmany = func(_ bool, amounts map[string]float64) (string, *daemons.JsonRpcError) { + if amounts["minerGood"] != 30.0 { + t.Errorf("minerGood payout = %v, want 30", amounts["minerGood"]) + } + if _, ok := amounts["minerBad"]; ok { + t.Errorf("unpayable minerBad must be excluded from sendmany: %v", amounts) + } + return "txid", nil + } + if err := h.pm.processPayments(); err != nil { + t.Fatal(err) + } + if v := h.mr.HGet("TEST:balances", "minerBad"); v != "70" { + t.Errorf("unpayable balance should carry over, got %q want 70", v) + } + if ok, _ := h.mr.SIsMember("TEST:blocks:confirmed", (&storage.PendingBlock{Hash: "blk600", TxHash: "tx600", Height: 600}).String()); !ok { + t.Error("block should still confirm when a payable miner was paid") + } +} + +// Wallet RPCs must reach the configured payment.daemon index, not always daemon 0. +func TestPayout_UsesConfiguredDaemonIndex(t *testing.T) { + mr, err := miniredis.Run() + if err != nil { + t.Fatal(err) + } + t.Cleanup(mr.Close) + host, portStr := splitHostPort(mr.Addr()) + port, _ := strconv.Atoi(portStr) + db := storage.NewStorage("TEST", &config.RedisOptions{Network: "tcp", Host: host, Port: port}) + + // daemon 0 is a mining node that has no wallet; every wallet RPC errors there. + mining := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + var req struct{ Id interface{} } + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &req) + writeRPC(rw, req.Id, nil, &daemons.JsonRpcError{Code: -32601, Message: "no wallet on mining node"}) + })) + t.Cleanup(mining.Close) + w := &fakeWallet{balance: "12.34567890"} + wallet := httptest.NewServer(http.HandlerFunc(w.handler)) + t.Cleanup(wallet.Close) + + toDaemon := func(s *httptest.Server) *config.DaemonOptions { + u, _ := url.Parse(s.URL) + p, _ := strconv.Atoi(u.Port()) + return &config.DaemonOptions{Host: u.Hostname(), Port: p} + } + dm := daemons.NewDaemonManager([]*config.DaemonOptions{toDaemon(mining), toDaemon(wallet)}, &config.CoinOptions{Name: "TEST"}) + pm := NewPaymentManager(&config.PaymentOptions{MinPayment: 0, MinConfirmations: 100, Daemon: 1}, &config.Recipient{Address: poolAddr, Type: "p2pkh"}, dm, db) + h := &harness{pm: pm, db: db, wallet: w, mr: mr} + + if err := h.pm.Init(); err != nil { + t.Fatalf("Init must reach the wallet at daemon index 1: %v", err) + } + h.seedRound(700, "tx700", map[string]float64{"minerA": 100}) + h.wallet.gettx = generateTx(120, 50.0) + h.wallet.sendmany = func(_ bool, _ map[string]float64) (string, *daemons.JsonRpcError) { return "txid", nil } + if err := h.pm.processPayments(); err != nil { + t.Fatal(err) + } + if len(h.wallet.SentBatches) != 1 { + t.Fatalf("payout should route to the wallet daemon, got %d sendmany calls", len(h.wallet.SentBatches)) + } +} + +func TestCoinSatConversion(t *testing.T) { + pm := &PaymentManager{Magnitude: 1e8} + if pm.CoinToSat(1.23456789) != 123456789 { + t.Errorf("CoinToSat = %d", pm.CoinToSat(1.23456789)) + } + if pm.SatToCoin(123456789) != 1.23456789 { + t.Errorf("SatToCoin = %v", pm.SatToCoin(123456789)) + } + if pm.CoinToSat(0) != 0 || pm.CoinToSat(-5) != 0 { + t.Error("non-positive coin must convert to 0 sat") + } +} diff --git a/pool/pool.go b/pool/pool.go index 957348c..099a0ef 100644 --- a/pool/pool.go +++ b/pool/pool.go @@ -19,6 +19,7 @@ import ( "github.com/mining-pool/not-only-mining-pool/engine" "github.com/mining-pool/not-only-mining-pool/jobs" "github.com/mining-pool/not-only-mining-pool/p2p" + "github.com/mining-pool/not-only-mining-pool/payments" "github.com/mining-pool/not-only-mining-pool/storage" "github.com/mining-pool/not-only-mining-pool/stratum" "github.com/mining-pool/not-only-mining-pool/utils" @@ -42,6 +43,7 @@ type Pool struct { Recipients []*config.Recipient ProtocolVersion int APIServer *api.Server + PaymentManager *payments.PaymentManager // Engine is set for non-GBT mining models (e.g. ethash). When set, Init // skips the entire Bitcoin daemon/jobmanager/payments machinery. @@ -68,13 +70,27 @@ func NewEnginePool(options *config.Options) *Pool { ss := stratum.NewStratumServer(options, nil, bm) ss.Engine = eng + ss.DB = db // engine-mode share persistence (stats/accounting) + + // Payout is available to bitcoin-family engine coins (e.g. Ravencoin/kawpow), + // whose shares carry a coinbase txid the payment processor can attribute. + // Non-bitcoin engines leave payments disabled; enabling one there fails fast + // in PaymentManager.Init when the wallet RPCs are unavailable. + var dm *daemons.DaemonManager + var pm *payments.PaymentManager + if !options.DisablePayment && options.PaymentOptions != nil { + dm = daemons.NewDaemonManager(options.Daemons, options.Coin) + pm = payments.NewPaymentManager(options.PaymentOptions, options.PoolAddress, dm, db) + } return &Pool{ - Options: options, - Engine: eng, - APIServer: apiServer, - StratumServer: ss, - Stats: NewStats(), + Options: options, + Engine: eng, + APIServer: apiServer, + StratumServer: ss, + DaemonManager: dm, + PaymentManager: pm, + Stats: NewStats(), } } @@ -135,12 +151,14 @@ func NewPool(options *config.Options) *Pool { jm := jobs.NewJobManager(options, dm, db) bm := bans.NewBanningManager(options.Banning) s := api.NewAPIServer(options, db) + pm := payments.NewPaymentManager(options.PaymentOptions, options.PoolAddress, dm, db) return &Pool{ - Options: options, - DaemonManager: dm, - JobManager: jm, - APIServer: s, + Options: options, + DaemonManager: dm, + JobManager: jm, + APIServer: s, + PaymentManager: pm, StratumServer: stratum.NewStratumServer(options, jm, bm), Magnitude: uint64(magnitude), @@ -149,13 +167,14 @@ func NewPool(options *config.Options) *Pool { } } -// func (p *Pool) Init() { if p.Engine != nil { // Engine-driven pool: the engine already fetched its first work in - // NewEnginePool; just serve stratum + API. No GBT/p2p/payment machinery. + // NewEnginePool; just serve stratum + API (no GBT/p2p machinery). Payout + // runs only for bitcoin-family engine coins that enabled it. p.StartStratumServer() p.APIServer.Serve() + p.startPaymentsIfEnabled() log.Warnf("Stratum Pool Server Started for %s [%s] using the %q engine, serving ports %v", p.Options.Coin.Name, strings.ToUpper(p.Options.Coin.Symbol), p.Engine.Name(), p.Stats.StratumPorts) return @@ -177,9 +196,24 @@ func (p *Pool) Init() { p.StartStratumServer() p.APIServer.Serve() + p.startPaymentsIfEnabled() + p.OutputPoolInfo() } +// startPaymentsIfEnabled initializes and serves the payout processor when +// payments are configured. It is a no-op otherwise, and fatal if the wallet +// cannot be validated (e.g. payments enabled on a non-bitcoin-family coin). +func (p *Pool) startPaymentsIfEnabled() { + if p.Options.DisablePayment || p.Options.PaymentOptions == nil { + return + } + if err := p.PaymentManager.Init(); err != nil { + log.Fatal("payment processing init failed: ", err) + } + go p.PaymentManager.Serve() +} + func (p *Pool) SetupP2PBlockNotify() { if p.Options.P2P == nil { return diff --git a/scripts/e2e/Dockerfile.flux b/scripts/e2e/Dockerfile.flux new file mode 100644 index 0000000..a780533 --- /dev/null +++ b/scripts/e2e/Dockerfile.flux @@ -0,0 +1,45 @@ +# Isolated e2e image for the Flux/ZelHash (equihash 125,4) payout leg. Kept +# separate from the main Dockerfile because fluxd needs ~775MB of zk-SNARK params +# — bundling them into the main image would slow every e2e run. Built + run only +# by the opt-in `e2e-flux` CI job. +# +# docker build -t nomp-e2e-flux -f scripts/e2e/Dockerfile.flux . +# docker run --rm nomp-e2e-flux +FROM golang:1.24-bookworm + +RUN apt-get update && apt-get install -y --no-install-recommends \ + redis-server ca-certificates curl python3 lsof libgomp1 \ + && rm -rf /var/lib/apt/lists/* + +# fluxd + flux-cli (amd64; the CI runner is amd64). +RUN set -eux; \ + curl -fsSL -o /tmp/flux.tar.gz \ + "https://github.com/RunOnFlux/fluxd/releases/download/v9.1.0/Flux-amd64-v9.1.0.tar.gz"; \ + mkdir -p /tmp/flux; tar xzf /tmp/flux.tar.gz -C /tmp/flux; \ + install -m755 /tmp/flux/fluxd /usr/local/bin/fluxd; \ + install -m755 /tmp/flux/flux-cli /usr/local/bin/flux-cli; \ + rm -rf /tmp/flux /tmp/flux.tar.gz; \ + fluxd --version | head -1 + +# zk-SNARK proving params (sapling + sprout-groth16, ~775MB) into the default +# ~/.zcash-params zcashd/fluxd search path. The canonical z.cash CDN is more +# reliable for the 725MB sprout file than the runonflux mirror; force HTTP/1.1 and +# retry/resume so a dropped stream doesn't fail the whole build. +RUN set -eux; \ + d=/root/.zcash-params; mkdir -p "$d"; \ + base="https://download.z.cash/downloads"; \ + for f in sapling-spend.params sapling-output.params sprout-groth16.params; do \ + curl -fSL --http1.1 --retry 8 --retry-delay 5 --retry-all-errors -C - -o "$d/$f" "$base/$f"; \ + done; \ + ln -sf "$d" /root/.flux-params; \ + ls -la "$d" + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +# The equihash engine is in the default build (no build tag). +RUN CGO_ENABLED=1 go build -o /usr/local/bin/nomp-pool ./cmd/nomp + +ENV E2E_WORK=/tmp/nomp-e2e +ENTRYPOINT ["bash", "scripts/e2e/payment-flux.sh"] diff --git a/scripts/e2e/gbt.sh b/scripts/e2e/gbt.sh index 646036f..64ff150 100755 --- a/scripts/e2e/gbt.sh +++ b/scripts/e2e/gbt.sh @@ -23,7 +23,7 @@ source "$(dirname "${BASH_SOURCE[0]}")/common.sh" NAME=$1 SYM=$2 DAEMON=$3 CLI=$4 ALGO=$5 RPCPORT=$6 SPORT=$7; shift 7 -PEERS=1 GBTRULES="segwit" BLOCKHASHER="" SHA256DBLOCK=1 CBHASH="sha256d" WAITREADY=0 ENGINE="" DIFF=0.0001 +PEERS=1 GBTRULES="segwit" BLOCKHASHER="" SHA256DBLOCK=1 CBHASH="sha256d" WAITREADY=0 ENGINE="" DIFF=0.0001 PREFUND=16 for kv in "$@"; do case "$kv" in peers=*) PEERS="${kv#*=}";; gbtRules=*) GBTRULES="${kv#*=}";; @@ -32,6 +32,10 @@ for kv in "$@"; do case "$kv" in coinbaseHasher=*) CBHASH="${kv#*=}";; waitReady=*) WAITREADY="${kv#*=}";; engine=*) ENGINE="${kv#*=}";; + # pre-fund block count. Some coins only activate their mining algo above a + # switch height on regtest (e.g. Monacoin uses scrypt below height 60 and + # lyra2rev2 above), so mine past it before the pool builds its block. + prefund=*) PREFUND="${kv#*=}";; # starting share difficulty. kawpow is CPU-heavy: a share at diff d costs # ~d*2^32 hashes, so kawpow needs a far lower diff than sha256d to be solvable. diff=*) DIFF="${kv#*=}";; @@ -86,19 +90,29 @@ w() { cli -rpcwallet=pool "$@" 2>/dev/null || cli "$@"; } # fall back to the default address. ADDR=$(w getnewaddress "" legacy 2>/dev/null || w getnewaddress "" 2>/dev/null || w getnewaddress) [ -z "$ADDR" ] && { fail "$SYM: could not get a wallet address"; exit 1; } -# A modest chain is enough (no coinbase maturity needed with disablePayment). -# Rapid generation pushes block times ahead of wall-clock via median-time-past; -# generating few blocks keeps that drift small. -cli -rpcwallet=pool generatetoaddress 16 "$ADDR" >/dev/null 2>&1 || cli generatetoaddress 16 "$ADDR" >/dev/null 2>&1 || true +# Mine the pre-fund chain (default 16; more when a coin activates its algo above a +# regtest switch height, e.g. Monacoin lyra2rev2 at 60). Rapid generation pushes +# block times ahead of wall-clock via median-time-past; the loop below waits it out. +cli -rpcwallet=pool generatetoaddress "$PREFUND" "$ADDR" >/dev/null 2>&1 || cli generatetoaddress "$PREFUND" "$ADDR" >/dev/null 2>&1 || true # The pool accepts a submitted nTime only within [GBT.curtime, now+7]. If the # chain time drifted ahead, wait for wall-clock to catch up so the window opens. gbt_curtime() { cli getblocktemplate "{\"rules\":[$(printf '"%s",' ${GBTRULES//,/ } | sed 's/,$//')]}" 2>/dev/null | python3 -c "import sys,json;print(json.load(sys.stdin).get('curtime',0))" 2>/dev/null || echo 0; } -for i in $(seq 1 30); do +for i in $(seq 1 120); do ct=$(gbt_curtime); now=$(date +%s) [ "${ct:-0}" -le "$((now+3))" ] && break sleep 1 done +# Ravencoin regtest hard-codes the KAWPOW activation TIME to the far future +# (3582830167 ≈ 2083); before it the node accepts only the legacy 80-byte header +# and rejects the pool's real kawpow block "Block decode failed". Activation is by +# block time, so setmocktime past it turns KAWPOW on (no recompile): the legacy +# pre-fund above is untouched, and blocks the pool builds now are real kawpow +# blocks the node accepts — so this leg actually LANDS a block, not just submits it. +if [ "$ENGINE" = kawpow ]; then + cli setmocktime 3600000000 >/dev/null 2>&1 + [ "$PEERS" = 2 ] && "$CLI" -datadir="$DIR2" -rpcport=$P2 -rpcuser=u -rpcpassword=p setmocktime 3600000000 >/dev/null 2>&1 +fi log "$SYM: node up, conns=$(cli getconnectioncount 2>/dev/null) height=$(cli getblockcount) addr=$ADDR" python3 - "$ADDR" "$NAME" "$SYM" "$ALGO" "$SHA256DBLOCK" "$BLOCKHASHER" "$CBHASH" "$GBTRULES" "$RPCPORT" "$SPORT" "$DIR" "$ENGINE" "$DIFF" <<'PY' @@ -143,9 +157,14 @@ log "$SYM: pool up" H0=$(cli getblockcount) if [ -n "$ENGINE" ]; then # engine-mode miners speak the engine's own stratum dialect and pull the - # header from the pool, so they only need the pool address + worker name. - # kawpow generates a light cache then brute-forces (CPU-heavy) — give it room. - with_timeout 360 "$DIR/miner" -pool 127.0.0.1:$SPORT -worker miner >"$DIR/miner.log" 2>&1 + # header from the pool, so they only need the pool address + worker name. The + # miner is one-shot (submits one solved share) and a share only lands a block if + # it also clears the network target, so loop until the chain grows. + for a in $(seq 1 8); do + with_timeout 60 "$DIR/miner" -pool 127.0.0.1:$SPORT -worker miner >>"$DIR/miner.log" 2>&1 + sleep 1 + [ "$(cli getblockcount)" -gt "$H0" ] && break + done else with_timeout 240 "$DIR/miner" -pool 127.0.0.1:$SPORT -algo "$ALGO" -coinbasehash "$CBHASH" -rpc "http://u:p@127.0.0.1:$RPCPORT" >"$DIR/miner.log" 2>&1 fi @@ -164,11 +183,10 @@ elif [ -n "$ENGINE" ] && grep -q "valid engine share" "$DIR/pool.log"; then ok "$SYM ($ALGO): pool validated the kawpow PoW end-to-end (no regtest block landed)" exit 0 elif grep -q "Found Block" "$DIR/pool.log"; then - # The pool assembled and submitted a full block (its PoW check passed); the - # node rejected it (e.g. high-hash at the target boundary, seen with the - # lyra2rev2 variant). The pool pipeline is validated; landing the block is a - # node/target-precision matter. - ok "$SYM ($ALGO): pool found + submitted a block (node rejected at the target boundary)" + # The pool built + submitted a full block but the node rejected it. Surface the + # reason so a real bug (hash/serialization mismatch) isn't hidden as "precision". + echo "$SYM DIAG: submit reject -> $(grep -iE "rejected the block|error with daemon" "$DIR/pool.log" | tail -1)" >&2 + ok "$SYM ($ALGO): pool found + submitted a block (node rejected at the boundary)" exit 0 else fail "$SYM ($ALGO): no block (height $H0 -> $H1)" diff --git a/scripts/e2e/payment-flux.sh b/scripts/e2e/payment-flux.sh new file mode 100644 index 0000000..79b6b07 --- /dev/null +++ b/scripts/e2e/payment-flux.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# End-to-end test that a Zcash-family ENGINE coin (Flux / ZelHash, Equihash 125,4) +# pays out through the shared payout processor. Like payment-rvn.sh it exercises +# the engine-mode integration: NewEnginePool constructing + serving the +# PaymentManager, the equihash engine starting against a LIVE fluxd (zcash-style +# getblocktemplate with coinbasetxn), and the payer validating a real fluxd wallet +# before classifyBlock/attribute/sendmany run against real wallet RPCs. +# +# fluxd needs ~775MB of zk params, so this runs only in the isolated e2e-flux CI +# job (Dockerfile.flux), never the main suite. kawpow/RVNPAY already proves the +# identical payout code path; this additionally proves the equihash engine drives +# a live node. It skips cleanly when fluxd is absent. +# +# Usage: payment-flux.sh [rpcport] [stratumport] +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +SYM=FLUXPAY +DAEMON=fluxd CLI=flux-cli +RPCPORT="${1:-16144}" SPORT="${2:-3051}" +COIN=Flux # must match config coin.name (the redis key prefix) + +have "$DAEMON" || { skip "$SYM: $DAEMON not found on PATH"; exit 2; } +ensure_redis || exit 1 + +DIR="$WORK/$SYM"; DIR2="$WORK/${SYM}b"; rm -rf "$DIR" "$DIR2"; mkdir -p "$DIR" "$DIR2" +P1=$((RPCPORT+1000)); P2=$((RPCPORT+1001)) +cli() { "$CLI" -regtest -datadir="$DIR" -rpcport=$RPCPORT -rpcuser=u -rpcpassword=p "$@"; } +w() { cli "$@"; } # fluxd uses a single default wallet +trap 'cli stop >/dev/null 2>&1; pkill -9 -f "$DIR/pool" 2>/dev/null; "$CLI" -datadir="$DIR2" -rpcport=$P2 -rpcuser=u -rpcpassword=p stop >/dev/null 2>&1; cleanup' EXIT + +mkconf() { cat > "$1/flux.conf" <"$DIR/node.log" 2>&1 +mkconf "$DIR2" $P2 $((P1+1)) +"$DAEMON" -datadir="$DIR2" -conf="$DIR2/flux.conf" $(rpcflags $P2 $((P1+1))) -connect=127.0.0.1:$P1 -daemon >"$DIR2/node.log" 2>&1 +for i in $(seq 1 150); do cli getblockcount >/dev/null 2>&1 && break; sleep 1; done +if ! cli getblockcount >/dev/null 2>&1; then + fail "$SYM: node did not come up" + echo "--- flux-cli getblockcount (unsuppressed) ---" >&2; cli getblockcount >&2 2>&1 || true + echo "--- node.log ---" >&2; tail -15 "$DIR/node.log" 2>/dev/null >&2 + echo "--- debug.log (rpc/http/bind/error) ---" >&2; grep -iE "http|rpc|bind|error|warmup|init message" "$DIR"/regtest/debug.log 2>/dev/null | tail -25 >&2 + exit 1 +fi +sleep 3 + +POOL_ADDR=$(w getnewaddress 2>/dev/null) +MINER_ADDR=$(w getnewaddress 2>/dev/null) +[ -z "$POOL_ADDR" ] || [ -z "$MINER_ADDR" ] && { fail "$SYM: could not get wallet addresses"; exit 1; } + +# A short chain is enough: this leg validates the engine↔live-fluxd path, not a +# spend (zcash coinbase is shielded, so a coinbase-only wallet can't fund a +# transparent sendmany — the payout code itself is proven by RVNPAY). Prefer +# generatetoaddress; fall back to the zcash-classic `generate`. +gen() { # + w generatetoaddress "$1" "$2" >/dev/null 2>&1 || w generate "$1" >/dev/null 2>&1 +} +gen 12 "$POOL_ADDR" +# Rapid regtest generation drifts block time ~1s/block ahead of wall-clock (Flux +# doesn't expose mediantime), so getblocktemplate fails "time-too-old" until the +# clock catches up. Wait until wall-clock passes the tip block's time (>= MTP). +tip_time() { cli getblock "$(cli getbestblockhash)" 2>/dev/null | python3 -c "import sys,json;print(json.load(sys.stdin).get('time',0))" 2>/dev/null || echo 0; } +for i in $(seq 1 120); do + [ "$(date +%s)" -gt "$(( $(tip_time) + 1 ))" ] && break; sleep 1 +done +H=$(cli getblockcount) +BLOCKHASH=$(cli getblockhash "$H") +TXID=$(cli getblock "$BLOCKHASH" | python3 -c "import sys,json;print(json.load(sys.stdin)['tx'][0])") +[ -z "$TXID" ] && { fail "$SYM: could not resolve coinbase txid"; exit 1; } +log "$SYM: node up height=$H pool=$POOL_ADDR miner=$MINER_ADDR block=$H tx=$TXID balance=$(w getbalance 2>/dev/null)" + +log "$SYM: building equihash pool (default build)" +build_pool "$DIR/pool" || { fail "$SYM: pool build failed"; exit 1; } + +python3 - "$POOL_ADDR" "$RPCPORT" "$SPORT" "$DIR" "$COIN" <<'PY' +import json,sys +addr,rpc,sport,d,coin=sys.argv[1:6] +pay={"interval":4,"minPayment":0,"daemon":0,"payMode":"prop","magnitude":1e8, + "minConfirmations":1,"addressCheckMethod":"validateaddress", + "sendManyDummy":"","omitSendManyDummy":False} +c={"coin":{"name":coin,"symbol":"FLUX"},"algorithm":{"name":"zelhash","multiplier":0}, + "engine":"zelhash","disablePayment":False,"payment":pay, + "poolAddress":{"address":addr,"type":"p2pkh"},"rewardRecipients":[], + "blockRefreshInterval":500,"jobRebroadcastTimeout":55,"connectionTimeout":600, + "banning":{"time":600,"invalidPercent":50,"checkThreshold":500,"purgeInterval":300}, + "ports":{sport:{"diff":0.001,"varDiff":None,"tls":None}}, + "daemons":[{"host":"127.0.0.1","port":int(rpc),"user":"u","password":"p"}], + "p2p":None,"api":{"host":"0.0.0.0","port":0}, + "storage":{"network":"tcp","host":"127.0.0.1","port":6379,"tls":None}} +json.dump(c,open(d+"/config.json","w"),indent=2) +PY + +# Inject the pending-block record the pool would have written on a solve, plus the +# round contribution the prop scheme attributes it to. +redis-cli keys "$COIN:*" 2>/dev/null | xargs -r redis-cli del >/dev/null 2>&1 +redis-cli sadd "$COIN:blocks:pending" "$BLOCKHASH:$TXID:$H:$MINER_ADDR:1" >/dev/null +redis-cli hset "$COIN:shares:round$H" "$MINER_ADDR" 1 >/dev/null + +free_port "$SPORT" +( cd "$DIR" && "$DIR/pool" -c config.json -l info >"$DIR/pool.log" 2>&1 & ) +# Pool start REQUIRES the equihash engine to have fetched + parsed a zcash +# getblocktemplate (coinbasetxn) from the live fluxd — the genuinely-new +# integration this leg validates. +if ! wait_pool_started "$DIR/pool.log"; then + fail "$SYM: equihash pool did not start against live fluxd" + grep -iE "engine|gbt|template|coinbasetxn|fatal|error" "$DIR/pool.log" | tail -15 + exit 1 +fi +# The engine pool must also construct + init the shared payer against the fluxd +# wallet (validateaddress ownership) — the engine-pool payout wiring. +if ! grep -q "payments:" "$DIR/pool.log"; then + fail "$SYM: payment processor did not init on the engine pool" + grep -iE "payment|fatal|error" "$DIR/pool.log" | tail -10 + exit 1 +fi + +# Best-effort: let the payer act on the injected block. A transparent sendmany +# can't be funded from coinbase on zcash (shielding), so this is reported, not +# required — the payout code path is proven end-to-end by RVNPAY. +paid="" +for i in $(seq 1 8); do + paid=$(redis-cli hget "$COIN:payouts" "$MINER_ADDR" 2>/dev/null) + [ -n "$paid" ] && python3 -c "import sys;sys.exit(0 if float('$paid')>0 else 1)" 2>/dev/null && break + sleep 2 +done +pkill -9 -f "$DIR/pool" 2>/dev/null + +if [ -n "$paid" ] && python3 -c "import sys;sys.exit(0 if float('$paid')>0 else 1)" 2>/dev/null; then + ok "$SYM (zelhash): equihash engine drove live fluxd; engine pool paid the miner — payout=$paid" +else + ok "$SYM (zelhash): equihash engine drove live fluxd + engine pool served the payer (transparent payout skipped — zcash coinbase shielding; payout path proven by RVNPAY)" +fi +exit 0 diff --git a/scripts/e2e/payment-rvn.sh b/scripts/e2e/payment-rvn.sh new file mode 100755 index 0000000..66eebb3 --- /dev/null +++ b/scripts/e2e/payment-rvn.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# End-to-end test that a bitcoin-family ENGINE coin (Ravencoin / kawpow) pays out +# through the shared payout processor — via REAL mining. It drives the full path +# the pool owns and inject would hide: the kawpow engine solves a block, submits +# it to ravend, resolves the coinbase txid (CheckBlockAccepted), PutShare records +# it as a pending block, then the payer attributes + sendmany-pays the finder. +# +# Ravencoin regtest hard-codes the KAWPOW activation TIME to 3582830167 (year +# 2083) — before it, nodes only accept the legacy 80-byte header, so a real kawpow +# block is rejected "Block decode failed". There is no CLI knob, but activation is +# by block time, so setmocktime past 2083 turns KAWPOW on without recompiling: we +# pre-fund with legacy blocks (mature coins for sendmany), then jump the clock so +# the pool's mined block is a real, node-accepted KAWPOW block. +# +# Usage: payment-rvn.sh [rpcport] [stratumport] +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +SYM=RVNPAY +DAEMON=ravend CLI=raven-cli +RPCPORT="${1:-19845}" SPORT="${2:-3049}" +COIN=Ravencoin +KAWPOW_TIME=3600000000 # > regtest nKAWPOWActivationTime (3582830167), < uint32 max + +have "$DAEMON" || { skip "$SYM: $DAEMON not found on PATH"; exit 2; } +ensure_redis || exit 1 + +DIR="$WORK/$SYM"; DIR2="$WORK/${SYM}b"; rm -rf "$DIR" "$DIR2"; mkdir -p "$DIR" "$DIR2" +P1=$((RPCPORT+1000)); P2=$((RPCPORT+1001)) +cli() { "$CLI" -datadir="$DIR" -rpcport=$RPCPORT -rpcuser=u -rpcpassword=p "$@"; } +cli2() { "$CLI" -datadir="$DIR2" -rpcport=$P2 -rpcuser=u -rpcpassword=p "$@"; } +w() { cli -rpcwallet=pool "$@" 2>/dev/null || cli "$@"; } +trap 'cli stop >/dev/null 2>&1; pkill -9 -f "$DIR/pool" 2>/dev/null; cli2 stop >/dev/null 2>&1; cleanup' EXIT + +mkconf() { cat > "$1/node.conf" <"$DIR/node.log" 2>&1 +mkconf "$DIR2" $P2 $((P1+1)) +"$DAEMON" -datadir="$DIR2" -conf="$DIR2/node.conf" $(rpcflags $P2 $((P1+1))) -connect=127.0.0.1:$P1 -daemon >"$DIR2/node.log" 2>&1 +for i in $(seq 1 60); do cli getblockcount >/dev/null 2>&1 && break; sleep 1; done +cli getblockcount >/dev/null 2>&1 || { fail "$SYM: node did not come up"; tail -20 "$DIR"/regtest/debug.log 2>/dev/null; exit 1; } +sleep 3 + +cli createwallet pool >/dev/null 2>&1 || cli loadwallet pool >/dev/null 2>&1 || true +POOL_ADDR=$(w getnewaddress "" legacy 2>/dev/null || w getnewaddress) +MINER_ADDR=$(w getnewaddress "" legacy 2>/dev/null || w getnewaddress) +[ -z "$POOL_ADDR" ] || [ -z "$MINER_ADDR" ] && { fail "$SYM: could not get wallet addresses"; exit 1; } + +# Pre-fund with LEGACY blocks (real time, pre-activation) so the wallet has mature +# coins for sendmany. Then jump both nodes' clocks past the KAWPOW activation time +# so every block built afterwards — including the pool's — is a real KAWPOW block. +w generatetoaddress 120 "$POOL_ADDR" >/dev/null 2>&1 +cli setmocktime $KAWPOW_TIME >/dev/null 2>&1 +cli2 setmocktime $KAWPOW_TIME >/dev/null 2>&1 +log "$SYM: node up height=$(cli getblockcount) pool=$POOL_ADDR miner=$MINER_ADDR (kawpow activated via mocktime)" + +log "$SYM: building kawpow pool + e2ervnminer (-tags kawpow)" +build_pool "$DIR/pool" kawpow || { fail "$SYM: pool build failed"; exit 1; } +build_tool e2ervnminer "$DIR/miner" kawpow || { fail "$SYM: miner build failed"; exit 1; } + +python3 - "$POOL_ADDR" "$RPCPORT" "$SPORT" "$DIR" "$COIN" <<'PY' +import json,sys +addr,rpc,sport,d,coin=sys.argv[1:6] +pay={"interval":4,"minPayment":0,"daemon":0,"payMode":"prop","magnitude":1e8, + "minConfirmations":1,"addressCheckMethod":"validateaddress", + "sendManyDummy":"","omitSendManyDummy":False} +c={"coin":{"name":coin,"symbol":"RVN","gbtRules":["segwit"]}, + "algorithm":{"name":"kawpow","multiplier":0,"sha256dBlockHasher":True}, + "engine":"kawpow","disablePayment":False,"payment":pay, + "poolAddress":{"address":addr,"type":"p2pkh"},"rewardRecipients":[], + "blockRefreshInterval":500,"jobRebroadcastTimeout":55,"connectionTimeout":600, + "banning":{"time":600,"invalidPercent":50,"checkThreshold":500,"purgeInterval":300}, + "ports":{sport:{"diff":0.00000001,"varDiff":None,"tls":None}}, + "daemons":[{"host":"127.0.0.1","port":int(rpc),"user":"u","password":"p"}], + "p2p":None,"api":{"host":"0.0.0.0","port":0}, + "storage":{"network":"tcp","host":"127.0.0.1","port":6379,"tls":None}} +json.dump(c,open(d+"/config.json","w"),indent=2) +PY + +redis-cli keys "$COIN:*" 2>/dev/null | xargs -r redis-cli del >/dev/null 2>&1 +free_port "$SPORT" +( cd "$DIR" && "$DIR/pool" -c config.json -l info >"$DIR/pool.log" 2>&1 & ) +wait_pool_started "$DIR/pool.log" || { fail "$SYM: pool did not start"; tail -30 "$DIR/pool.log"; exit 1; } + +# Mine until the pool's KAWPOW block is accepted (chain grows). The miner is +# one-shot per run, so loop; the pool records the pending block itself on accept. +H0=$(cli getblockcount) +LANDED=0 +for attempt in $(seq 1 8); do + with_timeout 40 "$DIR/miner" -pool 127.0.0.1:$SPORT -worker "$MINER_ADDR" >>"$DIR/miner.log" 2>&1 + sleep 1 + if [ "$(cli getblockcount)" -gt "$H0" ]; then LANDED=1; break; fi +done + +if [ "$LANDED" != 1 ]; then + fail "$SYM: pool never landed an accepted block" + echo "--- header size (should be 120 = kawpow) ---" >&2 + hh=$(cli getblockheader "$(cli getbestblockhash)" false 2>/dev/null); echo "tip header = $(( ${#hh} / 2 )) bytes" >&2 + grep -iE "block candidate|rejected the block|Block decode|error with daemon" "$DIR/pool.log" | tail -6 >&2 + grep -iE "ERROR|reject|CheckBlock|high-hash|bad-|proof of work" "$DIR"/regtest/debug.log 2>/dev/null | tail -12 >&2 + exit 1 +fi +log "$SYM: pool mined a real KAWPOW block, node accepted it (height $H0 -> $(cli getblockcount))" + +paid="" +for i in $(seq 1 30); do + paid=$(redis-cli hget "$COIN:payouts" "$MINER_ADDR" 2>/dev/null) + [ -n "$paid" ] && python3 -c "import sys;sys.exit(0 if float('$paid')>0 else 1)" 2>/dev/null && break + sleep 2 +done +pkill -9 -f "$DIR/pool" 2>/dev/null + +if [ -n "$paid" ] && python3 -c "import sys;sys.exit(0 if float('$paid')>0 else 1)" 2>/dev/null; then + received=$(w getreceivedbyaddress "$MINER_ADDR" 0 2>/dev/null) + ok "$SYM (kawpow): mined a real KAWPOW block → pool recorded it → payer paid the finder — payout=$paid received=$received" + exit 0 +fi +fail "$SYM (kawpow): block landed but the miner was not paid" +echo "--- pending / payouts ---" >&2 +redis-cli smembers "$COIN:blocks:pending" >&2; redis-cli hgetall "$COIN:payouts" >&2 +grep -iE "payment|sendmany|insufficient|orphan|gettransaction|coinbase" "$DIR/pool.log" | tail -20 >&2 +exit 1 diff --git a/scripts/e2e/payment.sh b/scripts/e2e/payment.sh new file mode 100755 index 0000000..038fbfe --- /dev/null +++ b/scripts/e2e/payment.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# Reproducible end-to-end test for the PAYOUT processor against a real bitcoind +# regtest wallet. Runs a payment-enabled pool once per payMode (prop, pplns, +# solo, pps): mine a block to the pool address, mature the coinbase, and assert +# the pool actually pays the miner via sendmany (redis payouts). One node is +# reused across modes; redis is cleared between them. +# +# Usage: payment.sh [rpcport] [stratumport] +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +SYM=PAY +DAEMON=bitcoind CLI=bitcoin-cli +RPCPORT="${1:-18455}" SPORT="${2:-3050}" +COIN=Bitcoin + +have "$DAEMON" || { skip "$SYM: $DAEMON not found on PATH"; exit 2; } +ensure_redis || exit 1 + +DIR="$WORK/$SYM"; rm -rf "$DIR"; mkdir -p "$DIR" +P1=$((RPCPORT+1000)) +cli() { "$CLI" -datadir="$DIR" -rpcport=$RPCPORT -rpcuser=u -rpcpassword=p "$@"; } +w() { cli -rpcwallet=pool "$@"; } +trap 'cli stop >/dev/null 2>&1; pkill -9 -f "$DIR/pool" 2>/dev/null; cleanup' EXIT + +cat > "$DIR/node.conf" <"$DIR/node.log" 2>&1 +for i in $(seq 1 30); do cli getblockcount >/dev/null 2>&1 && break; sleep 1; done +cli getblockcount >/dev/null 2>&1 || { fail "$SYM: node did not come up"; tail -8 "$DIR"/regtest/debug.log 2>/dev/null; exit 1; } + +# single loaded wallet so the pool's wallet RPCs (gettransaction/sendmany) route to it +cli createwallet pool >/dev/null 2>&1 || cli loadwallet pool >/dev/null 2>&1 || true +POOL_ADDR=$(w getnewaddress "" legacy) +MINER_ADDR=$(w getnewaddress "" legacy) # the miner's worker name == its payout address +JUNK_ADDR=$(w getnewaddress "" legacy) +[ -z "$POOL_ADDR" ] || [ -z "$MINER_ADDR" ] && { fail "$SYM: could not get wallet addresses"; exit 1; } + +# pre-fund the wallet with mature coins so sendmany can pay from them (payouts do +# not spend the freshly-mined coinbase, which stays immature), then wait for +# wall-clock to catch up to the chain time these rapid blocks pushed ahead. +w generatetoaddress 101 "$POOL_ADDR" >/dev/null 2>&1 +wait_chain_time() { + local n="${1:-30}" i ct + for i in $(seq 1 "$n"); do + ct=$(cli getblocktemplate '{"rules":["segwit"]}' 2>/dev/null | python3 -c "import sys,json;print(json.load(sys.stdin).get('curtime',0))" 2>/dev/null || echo 0) + [ "${ct:-0}" -le "$(( $(date +%s) + 3 ))" ] && return 0; sleep 1 + done +} +wait_chain_time 150 +log "$SYM: node up height=$(cli getblockcount) pool=$POOL_ADDR miner=$MINER_ADDR" + +log "$SYM: building pool + e2eminer" +build_pool "$DIR/pool" || { fail "$SYM: pool build failed"; exit 1; } +build_tool e2eminer "$DIR/miner" || { fail "$SYM: miner build failed"; exit 1; } + +# mkconfig — payment-enabled config for one payMode. +mkconfig() { + python3 - "$POOL_ADDR" "$RPCPORT" "$SPORT" "$DIR" "$COIN" "$1" <<'PY' +import json,sys +addr,rpc,sport,d,coin,mode=sys.argv[1:7] +# minConfirmations 1: the payer distributes a block once it has 1 confirmation +# (the maturity policy itself is unit-tested); sendmany still spends the mature +# pre-funded balance, so we avoid generating 100 blocks (which would drift the +# chain time past the pool's nTime window between modes). +pay={"interval":4,"minPayment":0,"daemon":0,"payMode":mode,"pplnsWindow":0,"ppsRate":0, + "magnitude":1e8,"minConfirmations":1,"addressCheckMethod":"getaddressinfo", + "sendManyDummy":"","omitSendManyDummy":False} +# pps pays per share-difficulty immediately. Shares are credited at the assigned +# vardiff (0.0001 here), so this rate lands a small, detectable payout that stays +# well under the wallet's mature balance. +if mode=="pps": pay["ppsRate"]=100 +c={"coin":{"name":coin,"symbol":"BTC"},"algorithm":{"name":"sha256d","multiplier":0,"sha256dBlockHasher":True}, + "disablePayment":False,"payment":pay, + "poolAddress":{"address":addr,"type":"p2pkh"},"rewardRecipients":[], + "blockRefreshInterval":500,"jobRebroadcastTimeout":55,"connectionTimeout":600, + "banning":{"time":600,"invalidPercent":50,"checkThreshold":500,"purgeInterval":300}, + "ports":{sport:{"diff":0.0001,"varDiff":{"minDiff":0.00001,"maxDiff":1000,"targetTime":15,"retargetTime":90,"variancePercent":30},"tls":None}}, + "daemons":[{"host":"127.0.0.1","port":int(rpc),"user":"u","password":"p"}], + "p2p":None,"api":{"host":"0.0.0.0","port":0}, + "storage":{"network":"tcp","host":"127.0.0.1","port":6379,"tls":None}} +json.dump(c,open(d+"/config.json","w"),indent=2) +PY +} + +paid_gt0() { [ -n "$1" ] && python3 -c "import sys;sys.exit(0 if float('$1')>0 else 1)" 2>/dev/null; } + +# run_mode — start pool, mine, mature, poll payouts. Echoes the paid +# amount on stdout; returns 0 iff the miner was paid. Diagnostics go to stderr. +run_mode() { + local mode="$1" + redis-cli keys "$COIN:*" 2>/dev/null | xargs -r redis-cli del >/dev/null 2>&1 + mkconfig "$mode" + free_port "$SPORT" + ( cd "$DIR" && "$DIR/pool" -c config.json -l info >"pool.$mode.log" 2>&1 & ) + wait_pool_started "$DIR/pool.$mode.log" || { tail -20 "$DIR/pool.$mode.log" >&2; return 1; } + wait_chain_time 30 # small per-mode drift from the previous confirm block + + local H0; H0=$(cli getblockcount) + with_timeout 240 "$DIR/miner" -pool 127.0.0.1:$SPORT -worker "$MINER_ADDR" -algo sha256d -coinbasehash sha256d -rpc "http://u:p@127.0.0.1:$RPCPORT" >"$DIR/miner.$mode.log" 2>&1 + sleep 2 + [ "$(cli getblockcount)" -gt "${H0:-0}" ] || { echo "no block mined" >&2; pkill -9 -f "$DIR/pool" 2>/dev/null; return 1; } + w generatetoaddress 1 "$JUNK_ADDR" >/dev/null 2>&1 # 1 confirmation (minConfirmations=1) + + local paid="" + for i in $(seq 1 20); do + paid=$(redis-cli hget "$COIN:payouts" "$MINER_ADDR" 2>/dev/null) + paid_gt0 "$paid" && break + sleep 2 + done + pkill -9 -f "$DIR/pool" 2>/dev/null; sleep 1 + echo "$paid" + paid_gt0 "$paid" +} + +declare -a RESULTS +FAILED=0 +for mode in prop pplns solo pps; do + log "$SYM: payMode=$mode" + paid=$(run_mode "$mode") + if paid_gt0 "$paid"; then + RESULTS+=("$mode=$paid") + else + RESULTS+=("$mode=FAIL"); FAILED=1 + grep -iE "payment|sendmany|paid|payout|maturit|insufficient|fatal" "$DIR/pool.$mode.log" 2>/dev/null | tail -6 + fi +done + +if [ "$FAILED" = 0 ]; then + ok "$SYM: every payMode paid the miner — ${RESULTS[*]}" + exit 0 +else + fail "$SYM: a payMode did not pay — ${RESULTS[*]}" + exit 1 +fi diff --git a/scripts/e2e/run-all.sh b/scripts/e2e/run-all.sh index e817f58..9985ffb 100755 --- a/scripts/e2e/run-all.sh +++ b/scripts/e2e/run-all.sh @@ -19,24 +19,26 @@ GBT_COINS=( "Litecoin LTC litecoind litecoin-cli scrypt 19443 3042 peers=2 gbtRules=mweb,segwit" "Dash DASH dashd dash-cli x11 19543 3043 peers=2 gbtRules= sha256dBlock=0" "Groestlcoin GRS groestlcoind groestlcoin-cli groestl 18444 3041 blockHasher=sha256 sha256dBlock=0 coinbaseHasher=sha256" - "Monacoin MONA monacoind monacoin-cli lyra2rev2 19643 3046 peers=2" + "Monacoin MONA monacoind monacoin-cli lyra2rev2 19643 3046 peers=2 prefund=70" "Vertcoin VTC vertcoind vertcoin-cli verthash 19743 3047 peers=2 waitReady=400" "Ravencoin RVN ravend raven-cli kawpow 19843 3048 peers=2 engine=kawpow waitReady=60 diff=0.00000001" ) -# --- engine coins (dedicated runners; non-bitcoind node interaction) -------- +# --- dedicated runners (non-bitcoind node interaction, or a special scenario) -- # sym script rpcport sport ENGINE_COINS=( "ETC ethash.sh 8545 3045" "XMR cryptonote.sh 18081 3040" "KAS kaspa.sh 16110 3044" + "PAY payment.sh 18455 3050" # payout processor: mine → mature → sendmany + "RVNPAY payment-rvn.sh 19845 3049" # engine-coin payout: kawpow pool serves the payer ) declare -a RESULTS record() { # local sym="$1" out="$2" if echo "$out" | grep -q "✅"; then - echo "$out" | grep -E "✅|⏭"; RESULTS+=("$sym ✅ real block") + echo "$out" | grep -E "✅|⏭"; RESULTS+=("$sym ✅ pass") elif echo "$out" | grep -q "⏭"; then echo "$out" | grep -E "⏭"; RESULTS+=("$sym ⏭ daemon not installed") else diff --git a/storage/block.go b/storage/block.go new file mode 100644 index 0000000..78d5a77 --- /dev/null +++ b/storage/block.go @@ -0,0 +1,55 @@ +package storage + +import ( + "fmt" + "strconv" + "strings" +) + +type BlockCategory string + +const ( + Immature BlockCategory = "immature" + Send BlockCategory = "send" + Orphan BlockCategory = "orphan" + Generate BlockCategory = "generate" + Receive BlockCategory = "receive" + Move BlockCategory = "move" + Kicked BlockCategory = "kicked" +) + +type PendingBlock struct { + Hash string + TxHash string + Height uint64 + Finder string // miner who submitted the block-solving share (solo payMode) + Mark int64 // pplns share-sequence at block time (window upper bound) +} + +func (pb *PendingBlock) String() string { + return strings.Join([]string{ + pb.Hash, pb.TxHash, strconv.FormatUint(pb.Height, 10), pb.Finder, strconv.FormatInt(pb.Mark, 10), + }, ":") +} + +// NewPendingBlockFromString parses a stored pending block. Finder and Mark are +// optional (older 3-field records parse fine) so the format can evolve. +func NewPendingBlockFromString(str string) (*PendingBlock, error) { + split := strings.Split(str, ":") + if len(split) < 3 { + return nil, fmt.Errorf("pending block string %s lacks element(s)", str) + } + + height, err := strconv.ParseUint(split[2], 10, 64) + if err != nil { + return nil, err + } + pb := &PendingBlock{Hash: split[0], TxHash: split[1], Height: height} + if len(split) > 3 { + pb.Finder = split[3] + } + if len(split) > 4 { + pb.Mark, _ = strconv.ParseInt(split[4], 10, 64) + } + return pb, nil +} diff --git a/storage/redis.go b/storage/redis.go index fc97bf9..471fe34 100644 --- a/storage/redis.go +++ b/storage/redis.go @@ -2,8 +2,10 @@ package storage import ( "context" + "encoding/json" "strconv" "strings" + "sync/atomic" "time" "github.com/go-redis/redis/v8" @@ -14,9 +16,32 @@ import ( var log = logging.Logger("storage") +// pplnsLogCap bounds the PPLNS share log (a ZSet of recent shares) so it can +// never grow without limit; the oldest entries are trimmed as new ones arrive. +// Atomic so tests can shrink it without racing the writer goroutine. NOTE: this +// rank cap is applied only outside PPS mode — see putShareNow — because it would +// otherwise drop shares the PPS cursor hasn't credited yet (underpayment). In PPS +// the log is trimmed by the cursor instead (ApplyPayments), retaining exactly the +// uncredited shares. +var pplnsLogCap atomic.Int64 + +func init() { pplnsLogCap.Store(200000) } + type DB struct { *redis.Client - coin string + coin string + shares chan shareJob + ppsMode bool // pps retains uncredited shares (trimmed by cursor, not by rank) +} + +// SetPPSMode switches the PPLNS-log retention policy: in PPS mode the log is not +// rank-capped (that could drop uncredited shares); it is trimmed by the payout +// cursor instead. Set once at startup, before shares flow. +func (s *DB) SetPPSMode(pps bool) { s.ppsMode = pps } + +type shareJob struct { + share *types.Share + accepted bool } func NewStorage(coinName string, options *config.RedisOptions) *DB { @@ -31,15 +56,33 @@ func NewStorage(coinName string, options *config.RedisOptions) *DB { log.Panicf("failed to connect to the redis server: %s %s", result, err) } - return &DB{ + db := &DB{ Client: client, coin: coinName, + shares: make(chan shareJob, 4096), } + go db.shareWriter() + return db } +// PutShare enqueues a share for the single ordered writer and returns at once, so +// a submitting client goroutine never blocks on redis. Ordering matters: shares +// are applied FIFO so a block's round seal (Rename roundCurrent -> round) +// captures exactly the shares recorded before it — a per-share goroutine let the +// seal race the round-contribution increments and mis-attribute rounds. func (s *DB) PutShare(share *types.Share, accepted bool) { + s.shares <- shareJob{share: share, accepted: accepted} +} + +// shareWriter drains the queue and applies each share in submission order. +func (s *DB) shareWriter() { + for job := range s.shares { + s.putShareNow(job.share, job.accepted) + } +} + +func (s *DB) putShareNow(share *types.Share, accepted bool) { now := time.Now().Unix() - strNow := strconv.FormatInt(now, 10) ppl := s.Pipeline() ctx := context.Background() @@ -48,9 +91,25 @@ func (s *DB) PutShare(share *types.Share, accepted bool) { ppl.SAdd(ctx, s.coin+":pool:miners", share.Miner) // miner index ppl.SAdd(ctx, s.coin+":miner:"+share.Miner+":rigs", share.Rig) // rig index + var seq int64 if share.ErrorCode == 0 { - log.Warn("recording valid share") - ppl.HIncrByFloat(ctx, s.coin+":pool:contrib", share.Miner, share.Diff) + log.Info("recording valid share") + // PPLNS log: append this share to a capped, monotonically-scored ZSet so a + // block can pay the last-N-difficulty window across rounds. seq is fetched + // synchronously so it can also mark the block's window upper bound. + seq, _ = s.Incr(ctx, s.coin+":shares:seq").Result() + ppl.ZAdd(ctx, s.coin+":shares:pplnslog", &redis.Z{ + Score: float64(seq), + Member: share.Miner + ":" + strconv.FormatFloat(share.Diff, 'f', -1, 64) + ":" + strconv.FormatInt(seq, 10), + }) + // Rank-cap the log so it can't grow unbounded — but ONLY outside PPS mode. + // In PPS the log is trimmed by the payout cursor (ApplyPayments), so a burst + // of >pplnsLogCap shares between runs never drops uncredited ones. + if !s.ppsMode { + ppl.ZRemRangeByRank(ctx, s.coin+":shares:pplnslog", 0, -(pplnsLogCap.Load() + 1)) + } + // current-round contribution, sealed to shares:round on a block. + ppl.HIncrByFloat(ctx, s.coin+":shares:roundCurrent", share.Miner, share.Diff) ppl.HIncrBy(ctx, s.coin+":miners:validShares", share.Miner, 1) ppl.HIncrBy(ctx, s.coin+":pool", "validShares", 1) @@ -84,14 +143,18 @@ func (s *DB) PutShare(share *types.Share, accepted bool) { // share is valid but block from share can be also invalid if accepted { log.Warn("recording valid block") - ppl.Rename(ctx, s.coin+":pool:contrib", s.coin+":pool:contrib:"+strconv.FormatInt(share.BlockHeight, 10)) - ppl.SAdd(ctx, s.coin+":blocks:pending", share.BlockHash) - ppl.HSetNX(ctx, s.coin+":blocks", share.BlockHash, strings.Join([]string{ - share.TxHash, - strconv.FormatInt(share.BlockHeight, 10), - share.Miner, - strNow, - }, ":")) + // Seal the current round to shares:round so the payer can pay + // exactly the miners who contributed to this block, and record the + // block as pending with the data the payer needs: hash (block id), + // txHash (coinbase, for gettransaction) and height (the round key). + ppl.Rename(ctx, s.coin+":shares:roundCurrent", s.coin+":shares:round"+strconv.FormatInt(share.BlockHeight, 10)) + ppl.SAdd(ctx, s.coin+":blocks:pending", (&PendingBlock{ + Hash: share.BlockHash, + TxHash: share.TxHash, + Height: uint64(share.BlockHeight), + Finder: share.Miner, // solo payMode + Mark: seq, // pplns window upper bound + }).String()) ppl.HIncrBy(ctx, s.coin+":pool", "validBlocks", 1) } else { @@ -116,7 +179,7 @@ func (s *DB) GetRigIndex(minerName string) ([]string, error) { // GetCurrentRoundCount will return a total diff of shares the miner submitted func (s *DB) GetMinerCurrentRoundContrib(minerName string) (float64, error) { - return s.HGet(context.Background(), s.coin+":shares:contrib", minerName).Float64() + return s.HGet(context.Background(), s.coin+":shares:roundCurrent", minerName).Float64() } // GetMinerTotalShares will return the number of all valid shares @@ -204,12 +267,223 @@ func (s *DB) GetMinerRigs(minerName string) (float64, error) { return s.HGet(context.Background(), s.coin+":shares:contrib", minerName).Float64() } -// ConfirmBlock alt one pending block to confirmed -func (s *DB) ConfirmBlock(blockHash string) (ok bool, err error) { - return s.SMove(context.Background(), s.coin+":blocks:pending", s.coin+":blocks:confirmed", blockHash).Result() +// PaymentUpdate is the atomic result of one payout run, applied by ApplyPayments +// in a single redis pipeline so balances, payouts and block state never diverge. +type PaymentUpdate struct { + Balances map[string]float64 // miner -> new carried-over balance (coin), absolute + Paid map[string]float64 // miner -> amount paid this run (coin), added to payouts + Confirmed []string // pending block strings moving to confirmed + Orphaned []string // pending block strings moving to orphaned + DeleteRounds []uint64 // sealed round heights whose shares are now accounted + PPSCursor int64 // pps: advance the credited-share cursor (0 = leave) +} + +// ApplyPayments persists one payout run atomically: it overwrites each miner's +// carried-over balance, adds this run's payouts, moves settled blocks out of the +// pending set, and drops the sealed rounds that were paid. +func (s *DB) ApplyPayments(u *PaymentUpdate) error { + ctx := context.Background() + // TxPipeline wraps the writes in MULTI/EXEC so a payout run's balance, payout, + // block-state, round-deletion and cursor updates apply all-or-nothing. + ppl := s.TxPipeline() + for miner, bal := range u.Balances { + ppl.HSet(ctx, s.coin+":balances", miner, strconv.FormatFloat(bal, 'f', -1, 64)) + } + for miner, paid := range u.Paid { + ppl.HIncrByFloat(ctx, s.coin+":payouts", miner, paid) + } + for _, b := range u.Confirmed { + ppl.SMove(ctx, s.coin+":blocks:pending", s.coin+":blocks:confirmed", b) + } + for _, b := range u.Orphaned { + ppl.SMove(ctx, s.coin+":blocks:pending", s.coin+":blocks:orphaned", b) + } + for _, h := range u.DeleteRounds { + ppl.Del(ctx, s.coin+":shares:round"+strconv.FormatUint(h, 10)) + } + if u.PPSCursor > 0 { + ppl.Set(ctx, s.coin+":pps:cursor", u.PPSCursor, 0) + // Shares up to the cursor are now credited — drop them so the PPS log stays + // bounded by the uncredited tail instead of the (skipped) rank cap. + ppl.ZRemRangeByScore(ctx, s.coin+":shares:pplnslog", "0", strconv.FormatInt(u.PPSCursor, 10)) + } + // This run's intent is now realized — drop it in the same atomic commit so a + // resumed run can't re-apply it. + ppl.Del(ctx, s.coin+":payouts:intent") + _, err := ppl.Exec(ctx) + return err +} + +// --- durable payout intent (double-spend guard) --- +// +// sendmany broadcasts on-chain, then ApplyPayments records it in redis. If the +// process dies between the two, a naive re-run would re-attribute the same blocks +// and pay again. To prevent that, settle writes the intended PaymentUpdate here +// BEFORE broadcasting and stamps the txid immediately AFTER; on the next run +// reconcile() finishes (if a txid is present) or halts for review (if not). + +// PutPayoutIntent records the update a payout run is about to broadcast (txid +// empty until the send returns). +func (s *DB) PutPayoutIntent(u *PaymentUpdate) error { + b, err := json.Marshal(u) + if err != nil { + return err + } + return s.HSet(context.Background(), s.coin+":payouts:intent", "update", b, "txid", "").Err() +} + +// SetPayoutIntentTxid stamps the broadcast transaction id onto the current intent +// as soon as sendmany returns, narrowing the ambiguous window to the RPC itself. +func (s *DB) SetPayoutIntentTxid(txid string) error { + return s.HSet(context.Background(), s.coin+":payouts:intent", "txid", txid).Err() +} + +// GetPayoutIntent returns the in-flight payout intent, if any. +func (s *DB) GetPayoutIntent() (update *PaymentUpdate, txid string, exists bool, err error) { + m, err := s.HGetAll(context.Background(), s.coin+":payouts:intent").Result() + if err != nil { + return nil, "", false, err + } + if len(m) == 0 { + return nil, "", false, nil + } + var u PaymentUpdate + if err := json.Unmarshal([]byte(m["update"]), &u); err != nil { + return nil, "", false, err + } + return &u, m["txid"], true, nil +} + +// DelPayoutIntent clears the intent (used when a send is known not to have +// broadcast — e.g. the node rejected it). +func (s *DB) DelPayoutIntent() error { + return s.Del(context.Background(), s.coin+":payouts:intent").Err() +} + +func (s *DB) GetAllMinerBalances() (map[string]float64, error) { + ss, err := s.HGetAll(context.Background(), s.coin+":balances").Result() + if err != nil { + return nil, err + } + balances := make(map[string]float64) + for minerName, strBalance := range ss { + balance, err := strconv.ParseFloat(strBalance, 64) + if err != nil { + return nil, err + } + balances[minerName] = balance + } + + return balances, nil +} + +func (s *DB) GetAllPendingBlocks() ([]*PendingBlock, error) { + strBlocks, err := s.SMembers(context.Background(), s.coin+":blocks:pending").Result() + if err != nil { + return nil, err + } + + blocks := make([]*PendingBlock, 0, len(strBlocks)) + for i := range strBlocks { + block, err := NewPendingBlockFromString(strBlocks[i]) + if err != nil { + return nil, err + } + + blocks = append(blocks, block) + } + + return blocks, nil +} + +// GetPPLNSShares returns per-miner share difficulty over the last-N window +// ending at uptoSeq: it walks the PPLNS log backward from that mark, summing +// difficulty per miner until the cumulative difficulty reaches window (window<=0 +// means no cap, bounded only by the log's own size). +func (s *DB) GetPPLNSShares(uptoSeq int64, window float64) (map[string]float64, error) { + members, err := s.ZRevRangeByScore(context.Background(), s.coin+":shares:pplnslog", &redis.ZRangeBy{ + Min: "-inf", + Max: strconv.FormatInt(uptoSeq, 10), + }).Result() + if err != nil { + return nil, err + } + + out := make(map[string]float64) + var cum float64 + for _, m := range members { + p := strings.SplitN(m, ":", 3) // miner:diff:seq + if len(p) < 2 { + continue + } + diff, err := strconv.ParseFloat(p[1], 64) + if err != nil { + continue + } + out[p[0]] += diff + cum += diff + if window > 0 && cum >= window { + break + } + } + return out, nil } -// KickBlock alt one pending block to kicked -func (s *DB) KickBlock(blockHash string) (ok bool, err error) { - return s.SMove(context.Background(), s.coin+":blocks:pending", s.coin+":blocks:kicked", blockHash).Result() +// GetPPSCursor returns the highest share sequence already credited in pps mode. +func (s *DB) GetPPSCursor() (int64, error) { + v, err := s.Get(context.Background(), s.coin+":pps:cursor").Int64() + if err == redis.Nil { + return 0, nil + } + return v, err +} + +// GetSharesSince returns per-miner share difficulty for every logged share newer +// than cursor, plus the highest sequence seen (so pps can advance the cursor). +func (s *DB) GetSharesSince(cursor int64) (map[string]float64, int64, error) { + zs, err := s.ZRangeByScoreWithScores(context.Background(), s.coin+":shares:pplnslog", &redis.ZRangeBy{ + Min: "(" + strconv.FormatInt(cursor, 10), // exclusive + Max: "+inf", + }).Result() + if err != nil { + return nil, cursor, err + } + + out := make(map[string]float64) + maxSeq := cursor + for _, z := range zs { + m, _ := z.Member.(string) + p := strings.SplitN(m, ":", 3) // miner:diff:seq + if len(p) < 2 { + continue + } + diff, err := strconv.ParseFloat(p[1], 64) + if err != nil { + continue + } + out[p[0]] += diff + if int64(z.Score) > maxSeq { + maxSeq = int64(z.Score) + } + } + return out, maxSeq, nil +} + +func (s *DB) GetRoundContrib(height uint64) (map[string]float64, error) { + m, err := s.HGetAll(context.Background(), s.coin+":shares:round"+strconv.FormatUint(height, 10)).Result() + if err != nil { + return nil, err + } + + contribMap := make(map[string]float64) + for minerName, strContrib := range m { + contrib, err := strconv.ParseFloat(strContrib, 64) + if err != nil { + return nil, err + } + + contribMap[minerName] = contrib + } + + return contribMap, nil } diff --git a/storage/share_test.go b/storage/share_test.go new file mode 100644 index 0000000..1875830 --- /dev/null +++ b/storage/share_test.go @@ -0,0 +1,120 @@ +package storage + +import ( + "context" + "net" + "strconv" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + + "github.com/mining-pool/not-only-mining-pool/config" + "github.com/mining-pool/not-only-mining-pool/types" +) + +func newTestDB(t *testing.T) (*DB, *miniredis.Miniredis) { + t.Helper() + mr, err := miniredis.Run() + if err != nil { + t.Fatal(err) + } + t.Cleanup(mr.Close) + host, portStr, _ := net.SplitHostPort(mr.Addr()) + port, _ := strconv.Atoi(portStr) + return NewStorage("T", &config.RedisOptions{Network: "tcp", Host: host, Port: port}), mr +} + +// waitZCard polls until the pplnslog reaches n members (the writer is async). +func waitZCard(t *testing.T, db *DB, key string, n int64) { + t.Helper() + for i := 0; i < 200; i++ { + if c, _ := db.ZCard(context.Background(), key).Result(); c == n { + return + } + time.Sleep(5 * time.Millisecond) + } + c, _ := db.ZCard(context.Background(), key).Result() + t.Fatalf("pplnslog card = %d, want %d", c, n) +} + +// Outside PPS, the log is rank-capped: only the newest pplnsLogCap shares survive. +func TestPPLNS_LogRankCapped(t *testing.T) { + pplnsLogCap.Store(3) + defer pplnsLogCap.Store(200000) + db, _ := newTestDB(t) // ppsMode false by default + for i := 0; i < 8; i++ { + db.PutShare(&types.Share{Miner: "A", Rig: "r", Diff: 1, BlockHeight: 100}, false) + } + waitZCard(t, db, "T:shares:pplnslog", 3) // trimmed to the cap +} + +// In PPS the rank cap is NOT applied — no uncredited share is dropped even past +// the cap; the log is trimmed by the payout cursor instead. +func TestPPS_LogRetainedUntilCredited(t *testing.T) { + pplnsLogCap.Store(3) + defer pplnsLogCap.Store(200000) + db, _ := newTestDB(t) + db.SetPPSMode(true) + for i := 0; i < 8; i++ { + db.PutShare(&types.Share{Miner: "A", Rig: "r", Diff: 1, BlockHeight: 100}, false) + } + waitZCard(t, db, "T:shares:pplnslog", 8) // all kept despite cap=3 + + // crediting up to seq 5 drops the credited shares, keeping the uncredited tail. + if err := db.ApplyPayments(&PaymentUpdate{PPSCursor: 5}); err != nil { + t.Fatal(err) + } + if c, _ := db.ZCard(context.Background(), "T:shares:pplnslog").Result(); c != 3 { + t.Errorf("after crediting to seq 5, pplnslog card = %d, want 3 (seq 6-8)", c) + } +} + +// The single ordered writer must seal a block's round with exactly the shares +// recorded before it: shares enqueued before the block land in round, +// the block's own share is included, and shares after it start the next round. +func TestPutShare_RoundSealIsOrdered(t *testing.T) { + db, mr := newTestDB(t) + + share := func(miner string, diff float64) *types.Share { + return &types.Share{Miner: miner, Rig: "r", Diff: diff, BlockHeight: 100} + } + // round 100: three shares before the block + db.PutShare(share("A", 1), false) + db.PutShare(share("B", 2), false) + db.PutShare(share("A", 1), false) + // block found at height 100 (a valid share that also solves the block) + blk := share("A", 1) + blk.BlockHex, blk.BlockHash, blk.TxHash = "00", "blkhash", "tx" + db.PutShare(blk, true) + // shares after the block belong to the next round + db.PutShare(&types.Share{Miner: "C", Rig: "r", Diff: 5, BlockHeight: 101}, false) + + // wait for the async writer to seal round 100 and start the next round. + for i := 0; i < 200; i++ { + if v, _ := mr.Get("T:shares:round100"); mr.Exists("T:shares:round100") || v != "" { + if c := mr.HGet("T:shares:roundCurrent", "C"); c != "" { + break + } + } + time.Sleep(5 * time.Millisecond) + } + + if got := mr.HGet("T:shares:round100", "A"); got != "3" { + t.Errorf("round100 minerA = %q, want 3 (two shares + the block share)", got) + } + if got := mr.HGet("T:shares:round100", "B"); got != "2" { + t.Errorf("round100 minerB = %q, want 2", got) + } + // the post-block share must NOT have leaked into the sealed round + if got := mr.HGet("T:shares:round100", "C"); got != "" { + t.Errorf("round100 minerC = %q, want empty (submitted after the block)", got) + } + // ...and must be in the new current round instead + if got := mr.HGet("T:shares:roundCurrent", "C"); got != "5" { + t.Errorf("roundCurrent minerC = %q, want 5", got) + } + if got := mr.HGet("T:shares:roundCurrent", "A"); got != "" { + t.Errorf("roundCurrent minerA = %q, want empty (its shares were sealed)", got) + } +} diff --git a/stratum/client.go b/stratum/client.go index 66dc683..c8469b7 100644 --- a/stratum/client.go +++ b/stratum/client.go @@ -19,6 +19,7 @@ import ( "github.com/mining-pool/not-only-mining-pool/daemons" "github.com/mining-pool/not-only-mining-pool/engine" "github.com/mining-pool/not-only-mining-pool/jobs" + "github.com/mining-pool/not-only-mining-pool/storage" "github.com/mining-pool/not-only-mining-pool/utils" "github.com/mining-pool/not-only-mining-pool/vardiff" ) @@ -55,6 +56,9 @@ type Client struct { // Engine, when non-nil, switches this client to a pluggable mining engine // (e.g. ethash). The Bitcoin/GBT path is used when Engine is nil. Engine engine.Engine + // DB persists engine-mode shares for stats/accounting (nil in the GBT path, + // which persists via JobManager.Storage instead). + DB *storage.DB } func NewStratumClient(subscriptionId []byte, socket net.Conn, options *config.Options, jm *jobs.JobManager, bm *bans.BanningManager) *Client { diff --git a/stratum/engine.go b/stratum/engine.go index 097fea9..afa565f 100644 --- a/stratum/engine.go +++ b/stratum/engine.go @@ -4,9 +4,11 @@ import ( "encoding/json" "math/big" "net" + "strings" "github.com/mining-pool/not-only-mining-pool/daemons" "github.com/mining-pool/not-only-mining-pool/engine" + "github.com/mining-pool/not-only-mining-pool/types" "github.com/mining-pool/not-only-mining-pool/utils" ) @@ -210,16 +212,41 @@ func (sc *Client) handleEngineMessage(message *daemons.JsonRpcRequest) { } share := sc.Engine.OnSubmit(sess, rawParamsToIface(message.Params)) + achievedDiff := share.Diff // engines report the achieved hash difficulty here + // vardiff boundary tolerance: honour a share that met the difficulty in + // force before the last retarget — the miner may still be on work targeted + // at the previous difficulty (engines that never emit ErrLowDiffShare, e.g. + // node-authoritative beam, are unaffected). + if share.ErrorCode == types.ErrLowDiffShare && sc.meetsPreviousEngineDiff(achievedDiff) { + share.ErrorCode = 0 + } valid := share.ErrorCode == 0 - // TODO(engine): persist share to storage for stats/payments (the engine - // already submits solved blocks to the node). Requires a storage handle - // on the engine-mode client; tracked in docs/PLUGGABLE_ENGINES_zh.md. if valid { + // Credit the ASSIGNED difficulty (the unit of share work), not the + // achieved hash difficulty — otherwise a block-solving share would + // dominate a PROP/PPLNS round (matches the GBT accounting fix). + share.Miner, share.Rig = splitWorker(sc.WorkerName) + share.Diff = sc.creditedEngineDiff(achievedDiff) + // A block is payable (recorded as pending) only if the engine resolved + // its coinbase txid, i.e. a bitcoin-family coin like Ravencoin whose + // wallet the payout processor can drive. Other engines' blocks are left + // as ordinary share contributions (their payout is coin-specific). + blockAccepted := share.TxHash != "" + if share.BlockHex != "" && !blockAccepted { + share.BlockHex = "" + } log.Info(sc.WorkerName, " submitted a valid engine share, diff=", share.Diff) + if sc.DB != nil { + sc.DB.PutShare(share, blockAccepted) + } sc.applyEngineVarDiff() } else { log.Warn(sc.WorkerName, "'s engine share invalid: ", share.ErrorCode.String()) + if sc.DB != nil { + share.Miner, share.Rig = splitWorker(sc.WorkerName) + sc.DB.PutShare(share, false) + } } if sc.ShouldBan(valid) { @@ -246,7 +273,9 @@ func (sc *Client) handleEngineMessage(message *daemons.JsonRpcRequest) { } // applyEngineVarDiff retargets the client difficulty and re-pushes work when the -// new target differs (ethash carries the target inside the work package). +// new target differs (engines carry the target inside the work package). It +// records the outgoing difficulty as PreviousDifficulty so a share the miner +// already found against the old target is still honoured (see meetsPreviousEngineDiff). func (sc *Client) applyEngineVarDiff() { if sc.VarDiff == nil || sc.CurrentDifficulty == nil { return @@ -254,12 +283,48 @@ func (sc *Client) applyEngineVarDiff() { cur, _ := sc.CurrentDifficulty.Float64() next := sc.VarDiff.CalcNextDiff(cur) if next != 0 && next != cur { + sc.PreviousDifficulty = sc.CurrentDifficulty sc.CurrentDifficulty = big.NewFloat(next) - log.Info("ethash vardiff retarget ", sc.WorkerName, " -> ", next) + log.Info("engine vardiff retarget ", sc.WorkerName, " ", cur, " -> ", next) sc.sendEngineWork() } } +// meetsPreviousEngineDiff reports whether an achieved share difficulty satisfies +// the difficulty in force before the most recent vardiff retarget. It lets a +// share found against the previous (lower) target through after a retarget +// raised the difficulty, mirroring the GBT path's prevDiff tolerance. +func (sc *Client) meetsPreviousEngineDiff(achieved float64) bool { + if sc.PreviousDifficulty == nil { + return false + } + prev, _ := sc.PreviousDifficulty.Float64() + return prev > 0 && achieved >= prev +} + +// creditedEngineDiff returns the assigned difficulty a valid share is worth: the +// current difficulty normally, or the previous one for a share tolerated at the +// retarget boundary (it only met the pre-retarget difficulty). +func (sc *Client) creditedEngineDiff(achieved float64) float64 { + cur := sc.engineDiff() + if achieved >= cur || sc.PreviousDifficulty == nil { + return cur + } + if prev, _ := sc.PreviousDifficulty.Float64(); prev > 0 && achieved >= prev { + return prev + } + return cur +} + +// splitWorker parses a "miner.rig" worker name into its payout address and rig, +// matching the GBT path so engine and Bitcoin shares key stats the same way. +func splitWorker(workerName string) (miner, rig string) { + if i := strings.IndexByte(workerName, '.'); i >= 0 { + return workerName[:i], workerName[i+1:] + } + return workerName, "unknown" +} + // rawParamsToIface decodes request params for engine dispatch: array params // become []interface{} element-wise; OBJECT params (CryptoNote login/submit) // are wrapped as a single-element slice holding the decoded map. diff --git a/stratum/engine_test.go b/stratum/engine_test.go index 348d9db..290557e 100644 --- a/stratum/engine_test.go +++ b/stratum/engine_test.go @@ -4,13 +4,18 @@ import ( "bufio" "bytes" "encoding/json" + "math/big" "net" + "strconv" "testing" "time" + "github.com/alicebob/miniredis/v2" + "github.com/mining-pool/not-only-mining-pool/config" "github.com/mining-pool/not-only-mining-pool/daemons" "github.com/mining-pool/not-only-mining-pool/engine" + "github.com/mining-pool/not-only-mining-pool/storage" "github.com/mining-pool/not-only-mining-pool/types" ) @@ -30,8 +35,12 @@ func (f *fakeConn) SetWriteDeadline(t time.Time) error { return nil } // fakeEngine implements engine.Engine plus the per-difficulty job capability. type fakeEngine struct { - submits [][]interface{} - valid bool + submits [][]interface{} + valid bool + shareDiff float64 // if >0, the achieved difficulty OnSubmit reports + blockHex string // if set, OnSubmit reports a block-solving share + txHash string // resolved coinbase txid (empty => non-payable block) + height int64 } func (f *fakeEngine) Name() string { return "fake" } @@ -48,7 +57,17 @@ func (f *fakeEngine) JobParamsForDifficulty(diff float64) []interface{} { } func (f *fakeEngine) OnSubmit(s engine.Session, params []interface{}) *types.Share { f.submits = append(f.submits, params) - share := &types.Share{Miner: s.WorkerName(), RemoteAddr: s.RemoteAddr(), Diff: s.Difficulty()} + diff := s.Difficulty() + if f.shareDiff > 0 { + diff = f.shareDiff + } + share := &types.Share{Miner: s.WorkerName(), RemoteAddr: s.RemoteAddr(), Diff: diff} + if f.blockHex != "" { + share.BlockHex = f.blockHex + share.BlockHash = "blkhash" + share.TxHash = f.txHash + share.BlockHeight = f.height + } if !f.valid { share.ErrorCode = types.ErrLowDiffShare } @@ -300,3 +319,132 @@ func TestEngineRejectsInvalidShareAndUnauthorized(t *testing.T) { t.Fatalf("invalid share should reply false with error: %v", msgs) } } + +// A valid engine share is persisted for stats/accounting, credited at the +// assigned difficulty and keyed by the miner's payout address (worker.rig split). +func TestEngineSharePersisted(t *testing.T) { + mr, err := miniredis.Run() + if err != nil { + t.Fatal(err) + } + defer mr.Close() + host, portStr, _ := net.SplitHostPort(mr.Addr()) + port, _ := strconv.Atoi(portStr) + db := storage.NewStorage("TESTENG", &config.RedisOptions{Network: "tcp", Host: host, Port: port}) + + eng := &fakeEngine{valid: true} + sc, _ := newEngineTestClient(eng) + sc.DB = db + sc.IsAuthorized = true + sc.WorkerName = "minerAddr.rig1" + sc.CurrentDifficulty = big.NewFloat(8) + + sc.HandleMessage(req("eth_submitWork", "0xn", "0xh", "0xm")) + + // PutShare runs asynchronously; poll briefly for the round contribution. + var got string + for i := 0; i < 100; i++ { + if got = mr.HGet("TESTENG:shares:roundCurrent", "minerAddr"); got != "" { + break + } + time.Sleep(5 * time.Millisecond) + } + if got != "8" { + t.Errorf("round contribution for minerAddr = %q, want 8 (assigned diff)", got) + } + if ok, _ := mr.SIsMember("TESTENG:miner:minerAddr:rigs", "rig1"); !ok { + t.Error("rig1 should be indexed under minerAddr") + } + if ok, _ := mr.SIsMember("TESTENG:pool:miners", "minerAddr"); !ok { + t.Error("minerAddr should be indexed in pool:miners") + } +} + +// A bitcoin-family engine block (coinbase txid resolved) is sealed as a pending, +// payable block; a block without a txid is kept as an ordinary share only. +func TestEngineBlockShareRecording(t *testing.T) { + newDB := func(t *testing.T) (*storage.DB, *miniredis.Miniredis) { + mr, err := miniredis.Run() + if err != nil { + t.Fatal(err) + } + t.Cleanup(mr.Close) + host, portStr, _ := net.SplitHostPort(mr.Addr()) + port, _ := strconv.Atoi(portStr) + return storage.NewStorage("TESTENG", &config.RedisOptions{Network: "tcp", Host: host, Port: port}), mr + } + submit := func(sc *Client) { + sc.IsAuthorized = true + sc.WorkerName = "minerAddr" + sc.CurrentDifficulty = big.NewFloat(8) + sc.HandleMessage(req("eth_submitWork", "0xn", "0xh", "0xm")) + } + + t.Run("payable block becomes pending", func(t *testing.T) { + db, mr := newDB(t) + sc, _ := newEngineTestClient(&fakeEngine{valid: true, blockHex: "00aa", txHash: "coinbaseTx", height: 42}) + sc.DB = db + submit(sc) + var members []string + for i := 0; i < 100; i++ { + members, _ = mr.SMembers("TESTENG:blocks:pending") + if len(members) > 0 { + break + } + time.Sleep(5 * time.Millisecond) + } + if len(members) != 1 || !bytes.Contains([]byte(members[0]), []byte("coinbaseTx")) { + t.Fatalf("expected one pending block carrying the coinbase txid, got %v", members) + } + if !mr.Exists("TESTENG:shares:round42") { + t.Error("the block's round should be sealed to round42") + } + }) + + t.Run("block without txid stays a share", func(t *testing.T) { + db, mr := newDB(t) + sc, _ := newEngineTestClient(&fakeEngine{valid: true, blockHex: "00aa", txHash: "", height: 42}) + sc.DB = db + submit(sc) + // the share is still recorded... + var got string + for i := 0; i < 100; i++ { + if got = mr.HGet("TESTENG:shares:roundCurrent", "minerAddr"); got != "" { + break + } + time.Sleep(5 * time.Millisecond) + } + if got != "8" { + t.Errorf("share contribution = %q, want 8", got) + } + // ...but no payable pending block is recorded. + if members, _ := mr.SMembers("TESTENG:blocks:pending"); len(members) != 0 { + t.Errorf("a block without a coinbase txid must not be recorded as pending: %v", members) + } + }) +} + +// After a vardiff retarget raises the difficulty, a share that still meets the +// previous difficulty is honoured; one below both is rejected. +func TestEngineVarDiffBoundaryTolerance(t *testing.T) { + eng := &fakeEngine{valid: false, shareDiff: 8} // engine would flag ErrLowDiffShare + sc, out := newEngineTestClient(eng) + sc.IsAuthorized = true + sc.PreviousDifficulty = big.NewFloat(8) // difficulty before the retarget + sc.CurrentDifficulty = big.NewFloat(16) // retarget raised it + + // achieved 8 meets the previous difficulty -> accepted despite ErrLowDiffShare + sc.HandleMessage(req("eth_submitWork", "0xn", "0xh", "0xm")) + msgs := drainResponses(t, out) + if len(msgs) != 1 || msgs[0]["result"] != true || msgs[0]["error"] != nil { + t.Fatalf("share meeting the previous difficulty should be accepted: %v", msgs) + } + + // achieved 4 is below both current and previous -> still rejected + eng.shareDiff = 4 + sc.HandleMessage(req("eth_submitWork", "0xn", "0xh", "0xm")) + msgs = drainResponses(t, out) + if len(msgs) != 1 || msgs[0]["result"] != false || msgs[0]["error"] == nil { + t.Fatalf("share below both difficulties should be rejected: %v", msgs) + } +} diff --git a/stratum/server.go b/stratum/server.go index 66e064d..a0f7f85 100644 --- a/stratum/server.go +++ b/stratum/server.go @@ -16,6 +16,7 @@ import ( "github.com/mining-pool/not-only-mining-pool/daemons" "github.com/mining-pool/not-only-mining-pool/engine" "github.com/mining-pool/not-only-mining-pool/jobs" + "github.com/mining-pool/not-only-mining-pool/storage" "github.com/mining-pool/not-only-mining-pool/vardiff" ) @@ -36,6 +37,8 @@ type Server struct { // Engine, when non-nil, drives an alternative mining model (e.g. ethash); // the server then skips the Bitcoin/GBT rebroadcast loop. Engine engine.Engine + // DB persists engine-mode shares (the GBT path persists via JobManager.Storage). + DB *storage.DB rebroadcastTicker *time.Ticker } @@ -130,6 +133,7 @@ func (ss *Server) HandleNewClient(socket net.Conn) []byte { subscriptionID := ss.SubscriptionCounter.Next() client := NewStratumClient(subscriptionID, socket, ss.Options, ss.JobManager, ss.BanningManager) client.Engine = ss.Engine + client.DB = ss.DB ss.clientsMu.Lock() ss.StratumClients[binary.LittleEndian.Uint64(subscriptionID)] = client ss.clientsMu.Unlock()