Implement Miner Options page, add webhook notifications
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
DataHoarder 2023-08-02 20:48:39 +02:00
parent 6fee8b6d62
commit e2885687b2
Signed by: DataHoarder
SSH key fingerprint: SHA256:OLTRf6Fl87G52SiR7sWLGNzlJt4WOX+tfI2yxo0z7xk
27 changed files with 1299 additions and 89 deletions

View file

@ -3,6 +3,7 @@ package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"flag"
"fmt"
@ -639,6 +640,47 @@ func main() {
})
serveMux.HandleFunc("/api/miner_webhooks/{miner:4[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+}", func(writer http.ResponseWriter, request *http.Request) {
minerId := mux.Vars(request)["miner"]
miner := indexDb.GetMinerByStringAddress(minerId)
if miner == nil {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusNotFound)
buf, _ := utils.MarshalJSON(struct {
Error string `json:"error"`
}{
Error: "miner_not_found",
})
_, _ = writer.Write(buf)
return
}
wh, _ := indexDb.GetMinerWebHooks(miner.Id())
defer wh.Close()
_ = httputils.StreamJsonIterator(request, writer, func() (int, *index.MinerWebHook) {
i, w := wh.Next()
if w == nil {
return 0, nil
}
newHook := &index.MinerWebHook{
Miner: w.Miner,
Type: w.Type,
}
// Sanitize private data by hashing
newHook.Url = types.Hash(sha256.Sum256([]byte(w.Url))).String()
settings := make(map[string]string)
for k, v := range w.Settings {
if strings.HasPrefix("send_", k) {
settings[k] = v
}
}
newHook.Settings = settings
return i, newHook
})
})
serveMux.HandleFunc("/api/miner_signed_action/{miner:4[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+}", func(writer http.ResponseWriter, request *http.Request) {
minerId := mux.Vars(request)["miner"]
miner := indexDb.GetMinerByStringAddress(minerId)
@ -682,21 +724,8 @@ func main() {
return
}
result := signedAction.Verify(miner.Address(), sig)
result := signedAction.VerifyFallbackToZero(os.Getenv("NET_SERVICE_ADDRESS"), miner.Address(), sig)
if result == address.ResultFail {
// check again with zero keys
zeroResult := signedAction.Verify(&address.ZeroPrivateKeyAddress, sig)
if zeroResult == address.ResultSuccessSpend || zeroResult == address.ResultSuccessView {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusUnauthorized)
buf, _ := utils.MarshalJSON(struct {
Error string `json:"error"`
}{
Error: "signature_verify_fail_zero_private_key",
})
_, _ = writer.Write(buf)
return
}
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusUnauthorized)
buf, _ := utils.MarshalJSON(struct {
@ -706,6 +735,26 @@ func main() {
})
_, _ = writer.Write(buf)
return
} else if result == address.ResultFailZeroSpend || result == address.ResultFailZeroView {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusUnauthorized)
buf, _ := utils.MarshalJSON(struct {
Error string `json:"error"`
}{
Error: "signature_verify_fail_zero_private_key",
})
_, _ = writer.Write(buf)
return
} else if result != address.ResultSuccessSpend && result != address.ResultSuccessView {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusUnauthorized)
buf, _ := utils.MarshalJSON(struct {
Error string `json:"error"`
}{
Error: "signature_verify_fail_unknown",
})
_, _ = writer.Write(buf)
return
}
switch signedAction.Action {
@ -797,6 +846,85 @@ func main() {
writer.WriteHeader(http.StatusOK)
return
}
case "add_webhook":
whType, _ := signedAction.Get("type")
whUrl, _ := signedAction.Get("url")
wh := &index.MinerWebHook{
Miner: miner.Id(),
Type: index.WebHookType(whType),
Url: whUrl,
Settings: make(map[string]string),
}
for _, e := range signedAction.Data {
if strings.HasPrefix(e.Key, "send_") {
wh.Settings[e.Key] = e.Value
}
}
err := wh.Verify()
if err != nil {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusBadRequest)
buf, _ := utils.MarshalJSON(struct {
Error string `json:"error"`
}{
Error: err.Error(),
})
_, _ = writer.Write(buf)
return
}
err = indexDb.InsertOrUpdateMinerWebHook(wh)
if err != nil {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusBadRequest)
buf, _ := utils.MarshalJSON(struct {
Error string `json:"error"`
}{
Error: err.Error(),
})
_, _ = writer.Write(buf)
return
}
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusOK)
return
case "remove_webhook":
urlHash, ok := signedAction.Get("url_hash")
urlType, ok2 := signedAction.Get("type")
if !ok || !ok2 || urlHash == "" {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusBadRequest)
buf, _ := utils.MarshalJSON(struct {
Error string `json:"error"`
}{
Error: "invalid_url",
})
_, _ = writer.Write(buf)
return
}
it, err := indexDb.GetMinerWebHooks(miner.Id())
if err != nil {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusBadRequest)
buf, _ := utils.MarshalJSON(struct {
Error string `json:"error"`
}{
Error: "internal_error",
})
_, _ = writer.Write(buf)
return
}
it.All(func(key int, value *index.MinerWebHook) (stop bool) {
if string(value.Type) == urlType && types.Hash(sha256.Sum256([]byte(value.Url))).String() == urlHash {
_ = indexDb.DeleteMinerWebHook(miner.Id(), value.Type)
}
return false
})
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusOK)
return
default:
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusBadRequest)
@ -1791,7 +1919,7 @@ func main() {
Miners uint64 `json:"miners"`
}
serveMux.HandleFunc("/api/stats/{kind:difficulty|miner_seen|miner_seen_window$}", func(writer http.ResponseWriter, request *http.Request) {
serveMux.HandleFunc("/api/stats/{kind:difficulty|miner_seen|miner_seen_window|version_seen$}", func(writer http.ResponseWriter, request *http.Request) {
switch mux.Vars(request)["kind"] {
case "difficulty":
result := make(chan *difficultyStatResult)

View file

@ -6,7 +6,7 @@ import (
"fmt"
"git.gammaspectra.live/P2Pool/go-monero/pkg/rpc/daemon"
"git.gammaspectra.live/P2Pool/p2pool-observer/cmd/index"
utils2 "git.gammaspectra.live/P2Pool/p2pool-observer/cmd/utils"
cmdutils "git.gammaspectra.live/P2Pool/p2pool-observer/cmd/utils"
"git.gammaspectra.live/P2Pool/p2pool-observer/monero"
"git.gammaspectra.live/P2Pool/p2pool-observer/monero/client"
"git.gammaspectra.live/P2Pool/p2pool-observer/monero/randomx"
@ -37,10 +37,15 @@ func main() {
p2poolApiHost := flag.String("api-host", "", "Host URL for p2pool go observer consensus")
fullMode := flag.Bool("full-mode", false, "Allocate RandomX dataset, uses 2GB of RAM")
debugListen := flag.String("debug-listen", "", "Provide a bind address and port to expose a pprof HTTP API on it.")
hookProxy := flag.String("hook-proxy", "", "socks5 proxy host:port for webhook requests")
flag.Parse()
client.SetDefaultClientSettings(fmt.Sprintf("http://%s:%d", *moneroHost, *moneroRpcPort))
if *hookProxy != "" {
cmdutils.SetWebHookProxy(*hookProxy)
}
p2api := p2poolapi.NewP2PoolApi(*p2poolApiHost)
if err := p2api.WaitSync(); err != nil {
@ -164,7 +169,7 @@ func main() {
ctx := context.Background()
scanHeader := func(h daemon.BlockHeader) error {
if err := utils2.FindAndInsertMainHeader(h, indexDb, func(b *sidechain.PoolBlock) {
if err := cmdutils.FindAndInsertMainHeader(h, indexDb, func(b *sidechain.PoolBlock) {
p2api.InsertAlternate(b)
}, client.GetDefaultClient(), indexDb.GetDifficultyByHeight, indexDb.GetByTemplateId, p2api.ByMainId, p2api.LightByMainHeight, func(b *sidechain.PoolBlock) error {
_, err := b.PreProcessBlock(p2api.Consensus(), &sidechain.NilDerivationCache{}, sidechain.PreAllocateShares(p2api.Consensus().ChainWindowSize*2), indexDb.GetDifficultyByHeight, indexDb.GetByTemplateId)
@ -275,7 +280,7 @@ func main() {
continue
}
if err := utils2.ProcessFullBlock(b, indexDb); err != nil {
if err := cmdutils.ProcessFullBlock(b, indexDb); err != nil {
log.Printf("error processing block %s at %d: %s", b.Id, b.Height, err)
}
}

View file

@ -3,7 +3,7 @@ package main
import (
"context"
"git.gammaspectra.live/P2Pool/p2pool-observer/cmd/index"
utils2 "git.gammaspectra.live/P2Pool/p2pool-observer/cmd/utils"
cmdutils "git.gammaspectra.live/P2Pool/p2pool-observer/cmd/utils"
"git.gammaspectra.live/P2Pool/p2pool-observer/p2pool/api"
types2 "git.gammaspectra.live/P2Pool/p2pool-observer/p2pool/types"
"git.gammaspectra.live/P2Pool/p2pool-observer/utils"
@ -325,8 +325,8 @@ func setupEventHandler(p2api *api.P2PoolApi, indexDb *index.Index) {
listenerLock.RLock()
defer listenerLock.RUnlock()
for _, b := range blocksToReport {
buf, err := utils.MarshalJSON(&utils2.JSONEvent{
Type: utils2.JSONEventSideBlock,
buf, err := utils.MarshalJSON(&cmdutils.JSONEvent{
Type: cmdutils.JSONEventSideBlock,
SideBlock: b,
FoundBlock: nil,
MainCoinbaseOutputs: nil,
@ -341,6 +341,19 @@ func setupEventHandler(p2api *api.P2PoolApi, indexDb *index.Index) {
l.Write(buf)
}
}
ts := time.Now().Unix()
// Send webhooks
go func(b *index.SideBlock) {
q, _ := indexDb.GetMinerWebHooks(b.Miner)
index.QueryIterate(q, func(_ int, w *index.MinerWebHook) (stop bool) {
if err := cmdutils.SendSideBlock(w, ts, b.MinerAddress, b); err != nil {
log.Printf("[WebHook] Error sending %s webhook to %s: type %s, url %s: %s", cmdutils.JSONEventSideBlock, b.MinerAddress.ToBase58(), w.Type, w.Url, err)
}
return false
})
}(b)
}
}()
}
@ -426,8 +439,8 @@ func setupEventHandler(p2api *api.P2PoolApi, indexDb *index.Index) {
listenerLock.RLock()
defer listenerLock.RUnlock()
for _, b := range unfoundBlocksToReport {
buf, err := utils.MarshalJSON(&utils2.JSONEvent{
Type: utils2.JSONEventOrphanedBlock,
buf, err := utils.MarshalJSON(&cmdutils.JSONEvent{
Type: cmdutils.JSONEventOrphanedBlock,
SideBlock: b,
FoundBlock: nil,
MainCoinbaseOutputs: nil,
@ -442,6 +455,19 @@ func setupEventHandler(p2api *api.P2PoolApi, indexDb *index.Index) {
l.Write(buf)
}
}
ts := time.Now().Unix()
// Send webhooks
go func(b *index.SideBlock) {
q, _ := indexDb.GetMinerWebHooks(b.Miner)
index.QueryIterate(q, func(_ int, w *index.MinerWebHook) (stop bool) {
if err := cmdutils.SendOrphanedBlock(w, ts, b.MinerAddress, b); err != nil {
log.Printf("[WebHook] Error sending %s webhook to %s: type %s, url %s: %s", cmdutils.JSONEventOrphanedBlock, b.MinerAddress.ToBase58(), w.Type, w.Url, err)
}
return false
})
}(b)
}
for _, b := range blocksToReport {
coinbaseOutputs := func() index.MainCoinbaseOutputs {
@ -458,8 +484,8 @@ func setupEventHandler(p2api *api.P2PoolApi, indexDb *index.Index) {
foundBlockBuffer.Remove(b)
continue
}
buf, err := utils.MarshalJSON(&utils2.JSONEvent{
Type: utils2.JSONEventFoundBlock,
buf, err := utils.MarshalJSON(&cmdutils.JSONEvent{
Type: cmdutils.JSONEventFoundBlock,
SideBlock: nil,
FoundBlock: b,
MainCoinbaseOutputs: coinbaseOutputs,
@ -474,6 +500,51 @@ func setupEventHandler(p2api *api.P2PoolApi, indexDb *index.Index) {
l.Write(buf)
}
}
includingHeight := max(b.EffectiveHeight, b.SideHeight)
if uint64(b.WindowDepth) > includingHeight {
includingHeight = 0
} else {
includingHeight -= uint64(b.WindowDepth)
}
// Send webhooks on outputs
for _, o := range coinbaseOutputs {
payout := &index.Payout{
Miner: o.Miner,
TemplateId: b.MainBlock.SideTemplateId,
SideHeight: b.SideHeight,
UncleOf: b.UncleOf,
MainId: b.MainBlock.Id,
MainHeight: b.MainBlock.Height,
Timestamp: b.MainBlock.Timestamp,
CoinbaseId: b.MainBlock.CoinbaseId,
Reward: o.Value,
PrivateKey: b.MainBlock.CoinbasePrivateKey,
Index: uint64(o.Index),
GlobalOutputIndex: o.GlobalOutputIndex,
IncludingHeight: includingHeight,
}
addr := o.MinerAddress
ts := time.Now().Unix()
// One goroutine per entry
go func() {
q, _ := indexDb.GetMinerWebHooks(payout.Miner)
index.QueryIterate(q, func(_ int, w *index.MinerWebHook) (stop bool) {
if err := cmdutils.SendFoundBlock(w, ts, addr, b, coinbaseOutputs); err != nil {
log.Printf("[WebHook] Error sending %s webhook to %s: type %s, url %s: %s", cmdutils.JSONEventFoundBlock, addr.ToBase58(), w.Type, w.Url, err)
}
if err := cmdutils.SendPayout(w, ts, addr, payout); err != nil {
log.Printf("[WebHook] Error sending %s webhook to %s: type %s, url %s: %s", cmdutils.JSONEventPayout, addr.ToBase58(), w.Type, w.Url, err)
}
return false
})
}()
}
}
}()
}

View file

@ -2,6 +2,7 @@ package index
import (
"git.gammaspectra.live/P2Pool/p2pool-observer/monero/address"
"git.gammaspectra.live/P2Pool/p2pool-observer/p2pool/sidechain"
"git.gammaspectra.live/P2Pool/p2pool-observer/types"
)
@ -24,7 +25,7 @@ type FoundBlock struct {
MinerAlias string `json:"miner_alias,omitempty"`
}
func (b *FoundBlock) ScanFromRow(i *Index, row RowScanInterface) error {
func (b *FoundBlock) ScanFromRow(_ *sidechain.Consensus, row RowScanInterface) error {
if err := row.Scan(
&b.MainBlock.Id, &b.MainBlock.Height, &b.MainBlock.Timestamp, &b.MainBlock.Reward, &b.MainBlock.CoinbaseId, &b.MainBlock.CoinbasePrivateKey, &b.MainBlock.Difficulty, &b.MainBlock.SideTemplateId,
&b.SideHeight, &b.Miner, &b.UncleOf, &b.EffectiveHeight, &b.WindowDepth, &b.WindowOutputs, &b.TransactionCount, &b.Difficulty, &b.CumulativeDifficulty, &b.Inclusion,

View file

@ -1112,7 +1112,7 @@ func (i *Index) QueryGlobalOutputIndices(indices []uint64) []*MatchedOutput {
if err := i.Query("SELECT "+MainCoinbaseOutputSelectFields+" FROM main_coinbase_outputs WHERE global_output_index = ANY($1) ORDER BY index ASC;", func(row RowScanInterface) error {
var o MainCoinbaseOutput
if err := o.ScanFromRow(i, row); err != nil {
if err := o.ScanFromRow(i.Consensus(), row); err != nil {
return err
}
if index := slices.Index(indices, o.GlobalOutputIndex); index != -1 {
@ -1133,7 +1133,7 @@ func (i *Index) QueryGlobalOutputIndices(indices []uint64) []*MatchedOutput {
if err := i.Query("SELECT "+MainLikelySweepTransactionSelectFields+" FROM main_likely_sweep_transactions WHERE $1::bigint[] && global_output_indices ORDER BY timestamp ASC;", func(row RowScanInterface) error {
var tx MainLikelySweepTransaction
if err := tx.ScanFromRow(i, row); err != nil {
if err := tx.ScanFromRow(i.Consensus(), row); err != nil {
return err
}
for _, globalOutputIndex := range tx.GlobalOutputIndices {
@ -1161,7 +1161,7 @@ func (i *Index) GetMainLikelySweepTransactionBySpendingGlobalOutputIndices(globa
entries := make([][]*MainLikelySweepTransaction, len(globalOutputIndices))
if err := i.Query("SELECT "+MainLikelySweepTransactionSelectFields+" FROM main_likely_sweep_transactions WHERE $1::bigint[] && spending_output_indices ORDER BY timestamp ASC;", func(row RowScanInterface) error {
var tx MainLikelySweepTransaction
if err := tx.ScanFromRow(i, row); err != nil {
if err := tx.ScanFromRow(i.Consensus(), row); err != nil {
return err
}
for _, globalOutputIndex := range tx.SpendingOutputIndices {
@ -1181,7 +1181,7 @@ func (i *Index) GetMainLikelySweepTransactionByGlobalOutputIndices(globalOutputI
entries := make([]*MainLikelySweepTransaction, len(globalOutputIndices))
if err := i.Query("SELECT "+MainLikelySweepTransactionSelectFields+" FROM main_likely_sweep_transactions WHERE $1::bigint[] && global_output_indices ORDER BY timestamp ASC;", func(row RowScanInterface) error {
var tx MainLikelySweepTransaction
if err := tx.ScanFromRow(i, row); err != nil {
if err := tx.ScanFromRow(i.Consensus(), row); err != nil {
return err
}
for _, globalOutputIndex := range tx.GlobalOutputIndices {
@ -1234,7 +1234,7 @@ func (i *Index) InsertOrUpdateMainLikelySweepTransaction(t *MainLikelySweepTrans
func (i *Index) GetMainCoinbaseOutputByMinerId(coinbaseId types.Hash, minerId uint64) *MainCoinbaseOutput {
var output MainCoinbaseOutput
if err := i.Query("SELECT "+MainCoinbaseOutputSelectFields+" FROM main_coinbase_outputs WHERE id = $1 AND miner = $2 ORDER BY index DESC;", func(row RowScanInterface) error {
if err := output.ScanFromRow(i, row); err != nil {
if err := output.ScanFromRow(i.Consensus(), row); err != nil {
return err
}
return nil
@ -1293,7 +1293,7 @@ func (i *Index) Close() error {
func (i *Index) scanMiner(rows *sql.Rows) *Miner {
if rows.Next() {
m := &Miner{}
if m.ScanFromRow(i, rows) == nil {
if m.ScanFromRow(i.Consensus(), rows) == nil {
return m
}
}
@ -1400,3 +1400,50 @@ func (i *Index) InsertOrUpdatePoolBlock(b *sidechain.PoolBlock, inclusion BlockI
return nil
}
func (i *Index) GetMinerWebHooks(minerId uint64) (QueryIterator[MinerWebHook], error) {
if stmt, err := i.handle.Prepare("SELECT miner, type, url, settings FROM miner_webhooks WHERE miner = $1;"); err != nil {
return nil, err
} else {
if r, err := queryStatement[MinerWebHook](i, stmt, minerId); err != nil {
defer stmt.Close()
return nil, err
} else {
r.closer = func() {
stmt.Close()
}
return r, nil
}
}
}
func (i *Index) DeleteMinerWebHook(minerId uint64, hookType WebHookType) error {
return i.Query("DELETE FROM miner_webhooks WHERE miner = $1 AND type = $2", func(row RowScanInterface) error {
return nil
}, minerId, hookType)
}
func (i *Index) InsertOrUpdateMinerWebHook(w *MinerWebHook) error {
metadataJson, _ := utils.MarshalJSON(w.Settings)
if w.Settings == nil {
metadataJson = []byte{'{', '}'}
}
if tx, err := i.handle.BeginTx(context.Background(), nil); err != nil {
return err
} else {
defer tx.Rollback()
if _, err := tx.Exec(
"INSERT INTO miner_webhooks (miner, type, url, settings) VALUES ($1, $2, $3, $4::jsonb) ON CONFLICT (miner, type) DO UPDATE SET url = $3, settings = $4;",
w.Miner,
w.Type,
w.Url,
metadataJson,
); err != nil {
return err
}
return tx.Commit()
}
}

View file

@ -2,6 +2,7 @@ package index
import (
"git.gammaspectra.live/P2Pool/p2pool-observer/monero/crypto"
"git.gammaspectra.live/P2Pool/p2pool-observer/p2pool/sidechain"
"git.gammaspectra.live/P2Pool/p2pool-observer/types"
"git.gammaspectra.live/P2Pool/p2pool-observer/utils"
)
@ -34,7 +35,7 @@ func (b *MainBlock) SetMetadata(key string, v any) {
b.Metadata[key] = v
}
func (b *MainBlock) ScanFromRow(i *Index, row RowScanInterface) error {
func (b *MainBlock) ScanFromRow(_ *sidechain.Consensus, row RowScanInterface) error {
var metadataBuf []byte
b.Metadata = make(map[string]any)
if err := row.Scan(&b.Id, &b.Height, &b.Timestamp, &b.Reward, &b.CoinbaseId, &b.Difficulty, &metadataBuf, &b.SideTemplateId, &b.CoinbasePrivateKey); err != nil {

View file

@ -2,6 +2,7 @@ package index
import (
"git.gammaspectra.live/P2Pool/p2pool-observer/monero/address"
"git.gammaspectra.live/P2Pool/p2pool-observer/p2pool/sidechain"
"git.gammaspectra.live/P2Pool/p2pool-observer/types"
)
@ -28,7 +29,7 @@ type MainCoinbaseOutput struct {
MinerAlias string `json:"miner_alias,omitempty"`
}
func (o *MainCoinbaseOutput) ScanFromRow(i *Index, row RowScanInterface) error {
func (o *MainCoinbaseOutput) ScanFromRow(_ *sidechain.Consensus, row RowScanInterface) error {
if err := row.Scan(&o.Id, &o.Index, &o.GlobalOutputIndex, &o.Miner, &o.Value); err != nil {
return err
}

View file

@ -4,6 +4,7 @@ import (
"errors"
"git.gammaspectra.live/P2Pool/p2pool-observer/monero/address"
"git.gammaspectra.live/P2Pool/p2pool-observer/monero/crypto"
"git.gammaspectra.live/P2Pool/p2pool-observer/p2pool/sidechain"
"git.gammaspectra.live/P2Pool/p2pool-observer/types"
"git.gammaspectra.live/P2Pool/p2pool-observer/utils"
"github.com/lib/pq"
@ -35,7 +36,7 @@ type MainLikelySweepTransaction struct {
Address *address.Address `json:"address"`
}
func (t *MainLikelySweepTransaction) ScanFromRow(i *Index, row RowScanInterface) error {
func (t *MainLikelySweepTransaction) ScanFromRow(consensus *sidechain.Consensus, row RowScanInterface) error {
var spendPub, viewPub crypto.PublicKeyBytes
var resultBuf, matchBuf []byte
var spendingOutputIndices, globalOutputIndices pq.Int64Array
@ -55,7 +56,7 @@ func (t *MainLikelySweepTransaction) ScanFromRow(i *Index, row RowScanInterface)
t.GlobalOutputIndices[j] = uint64(ix)
}
network, err := i.consensus.NetworkType.AddressNetwork()
network, err := consensus.NetworkType.AddressNetwork()
if err != nil {
return errors.New("unknown network type")
}

View file

@ -4,6 +4,7 @@ import (
"database/sql"
"git.gammaspectra.live/P2Pool/p2pool-observer/monero/address"
"git.gammaspectra.live/P2Pool/p2pool-observer/monero/crypto"
"git.gammaspectra.live/P2Pool/p2pool-observer/p2pool/sidechain"
)
const MinerSelectFields = "id, alias, spend_public_key, view_public_key"
@ -29,13 +30,13 @@ func (m *Miner) Address() *address.Address {
return &m.addr
}
func (m *Miner) ScanFromRow(i *Index, row RowScanInterface) error {
func (m *Miner) ScanFromRow(consensus *sidechain.Consensus, row RowScanInterface) error {
var spendPub, viewPub crypto.PublicKeyBytes
if err := row.Scan(&m.id, &m.alias, &spendPub, &viewPub); err != nil {
return err
}
network, err := i.consensus.NetworkType.AddressNetwork()
network, err := consensus.NetworkType.AddressNetwork()
if err != nil {
return err
}

124
cmd/index/miner_webhook.go Normal file
View file

@ -0,0 +1,124 @@
package index
import (
"errors"
"git.gammaspectra.live/P2Pool/p2pool-observer/p2pool/sidechain"
"git.gammaspectra.live/P2Pool/p2pool-observer/utils"
"net/url"
"slices"
"strings"
)
type MinerWebHook struct {
Miner uint64 `json:"miner"`
Type WebHookType `json:"type"`
Url string `json:"url"`
Settings map[string]string `json:"settings"`
Consensus *sidechain.Consensus `json:"-"`
}
type WebHookType string
const (
WebHookSlack WebHookType = "slack"
WebHookDiscord WebHookType = "discord"
WebHookTelegram WebHookType = "telegram"
WebHookMatrixHookshot WebHookType = "matrix-hookshot"
WebHookCustom WebHookType = "custom"
)
var disallowedCustomHookHosts = []string{
"hooks.slack.com",
"discord.com",
"api.telegram.org",
}
var disallowedCustomHookPorts = []string{}
func (w *MinerWebHook) ScanFromRow(consensus *sidechain.Consensus, row RowScanInterface) error {
var settingsBuf []byte
w.Consensus = consensus
w.Settings = make(map[string]string)
if err := row.Scan(&w.Miner, &w.Type, &w.Url, &settingsBuf); err != nil {
return err
} else if err = utils.UnmarshalJSON(settingsBuf, &w.Settings); err != nil {
return err
}
return nil
}
func (w *MinerWebHook) Verify() error {
uri, err := url.Parse(w.Url)
if err != nil {
return err
}
switch w.Type {
case WebHookSlack:
if uri.Scheme != "https" {
return errors.New("invalid URL scheme, expected https")
}
if uri.Host != "hooks.slack.com" {
return errors.New("invalid hook host, expected hooks.slack.com")
}
if uri.Port() != "" {
return errors.New("unexpected port")
}
if !strings.HasPrefix(uri.Path, "/services/") {
return errors.New("invalid hook path start")
}
case WebHookDiscord:
if uri.Scheme != "https" {
return errors.New("invalid URL scheme, expected https")
}
if uri.Host != "discord.com" {
return errors.New("invalid hook host, expected discord.com")
}
if uri.Port() != "" {
return errors.New("unexpected port")
}
if !strings.HasPrefix(uri.Path, "/api/webhooks/") {
return errors.New("invalid hook path start")
}
case WebHookTelegram:
if uri.Scheme != "https" {
return errors.New("invalid URL scheme, expected https")
}
if uri.Host != "api.telegram.org" {
return errors.New("invalid hook host, expected api.telegram.org")
}
if uri.Port() != "" {
return errors.New("unexpected port")
}
if !strings.HasPrefix(uri.Path, "/bot") {
return errors.New("invalid hook path start")
}
if !strings.HasSuffix(uri.Path, "/sendMessage") {
return errors.New("invalid hook path end")
}
case WebHookMatrixHookshot:
if uri.Scheme != "https" {
return errors.New("invalid URL scheme, expected https")
}
if slices.Contains(disallowedCustomHookHosts, strings.ToLower(uri.Hostname())) {
return errors.New("disallowed hook host")
}
if uri.Port() != "" {
return errors.New("unexpected port")
}
case WebHookCustom:
if uri.Scheme != "https" && uri.Scheme != "http" {
return errors.New("invalid URL scheme, expected http or https")
}
if slices.Contains(disallowedCustomHookHosts, strings.ToLower(uri.Hostname())) {
return errors.New("disallowed hook host")
}
if slices.Contains(disallowedCustomHookPorts, strings.ToLower(uri.Port())) {
return errors.New("disallowed hook port")
}
default:
return errors.New("unsupported hook type")
}
return nil
}

View file

@ -2,6 +2,7 @@ package index
import (
"git.gammaspectra.live/P2Pool/p2pool-observer/monero/crypto"
"git.gammaspectra.live/P2Pool/p2pool-observer/p2pool/sidechain"
"git.gammaspectra.live/P2Pool/p2pool-observer/types"
)
@ -21,7 +22,7 @@ type Payout struct {
IncludingHeight uint64 `json:"including_height"`
}
func (p *Payout) ScanFromRow(i *Index, row RowScanInterface) error {
func (p *Payout) ScanFromRow(_ *sidechain.Consensus, row RowScanInterface) error {
if err := row.Scan(&p.Miner, &p.MainId, &p.MainHeight, &p.Timestamp, &p.CoinbaseId, &p.PrivateKey, &p.TemplateId, &p.SideHeight, &p.UncleOf, &p.Reward, &p.Index, &p.GlobalOutputIndex, &p.IncludingHeight); err != nil {
return err
}

View file

@ -3,6 +3,7 @@ package index
import (
"database/sql"
"errors"
"git.gammaspectra.live/P2Pool/p2pool-observer/p2pool/sidechain"
)
type RowScanInterface interface {
@ -10,7 +11,7 @@ type RowScanInterface interface {
}
type Scannable interface {
ScanFromRow(i *Index, row RowScanInterface) error
ScanFromRow(consensus *sidechain.Consensus, row RowScanInterface) error
}
type QueryIterator[V any] interface {
@ -21,6 +22,9 @@ type QueryIterator[V any] interface {
}
func QueryIterate[V any](i QueryIterator[V], f IterateFunction[int, *V]) {
if i == nil {
return
}
defer i.Close()
i.All(f)
@ -86,8 +90,8 @@ func queryStatement[V any](index *Index, stmt *sql.Stmt, params ...any) (*QueryR
return nil, err
} else {
return &QueryResult[V]{
index: index,
rows: rows,
consensus: index.consensus,
rows: rows,
}, err
}
}
@ -122,11 +126,11 @@ func (r *FakeQueryResult[V]) Close() {
}
type QueryResult[V any] struct {
index *Index
rows *sql.Rows
closer func()
i int
err error
consensus *sidechain.Consensus
rows *sql.Rows
closer func()
i int
err error
}
func (r *QueryResult[V]) All(f IterateFunction[int, *V]) (complete bool) {
@ -145,7 +149,7 @@ func (r *QueryResult[V]) All(f IterateFunction[int, *V]) (complete bool) {
func (r *QueryResult[V]) Next() (int, *V) {
if r.rows.Next() {
var v V
if r.err = any(&v).(Scannable).ScanFromRow(r.index, r.rows); r.err != nil {
if r.err = any(&v).(Scannable).ScanFromRow(r.consensus, r.rows); r.err != nil {
return 0, nil
}
r.i++

View file

@ -6,6 +6,16 @@ CREATE TABLE IF NOT EXISTS miners (
UNIQUE (spend_public_key, view_public_key)
);
CREATE TABLE IF NOT EXISTS miner_webhooks (
miner bigint NOT NULL,
type varchar NOT NULL,
url varchar NOT NULL,
settings jsonb NOT NULL DEFAULT '{}', -- settings to know when to trigger or other required settings for the method
UNIQUE (miner, type),
FOREIGN KEY (miner) REFERENCES miners (id)
);
CREATE INDEX IF NOT EXISTS miner_webhooks_miner_idx ON miner_webhooks (miner);
CREATE TABLE IF NOT EXISTS side_blocks (
main_id bytea PRIMARY KEY, -- mainchain id, on Monero network

View file

@ -162,7 +162,7 @@ func (b *SideBlock) IsOrphan() bool {
return b.Inclusion == InclusionOrphan
}
func (b *SideBlock) ScanFromRow(i *Index, row RowScanInterface) error {
func (b *SideBlock) ScanFromRow(_ *sidechain.Consensus, row RowScanInterface) error {
if err := row.Scan(&b.MainId, &b.MainHeight, &b.TemplateId, &b.SideHeight, &b.ParentTemplateId, &b.Miner, &b.UncleOf, &b.EffectiveHeight, &b.Nonce, &b.ExtraNonce, &b.Timestamp, &b.SoftwareId, &b.SoftwareVersion, &b.WindowDepth, &b.WindowOutputs, &b.TransactionCount, &b.Difficulty, &b.CumulativeDifficulty, &b.PowDifficulty, &b.PowHash, &b.Inclusion); err != nil {
return err
}

View file

@ -15,6 +15,7 @@ const (
JSONEventSideBlock = "side_block"
JSONEventFoundBlock = "found_block"
JSONEventOrphanedBlock = "orphaned_block"
JSONEventPayout = "payout"
)
type JSONEvent struct {
@ -22,6 +23,7 @@ type JSONEvent struct {
SideBlock *index.SideBlock `json:"side_block,omitempty"`
FoundBlock *index.FoundBlock `json:"found_block,omitempty"`
MainCoinbaseOutputs index.MainCoinbaseOutputs `json:"main_coinbase_outputs,omitempty"`
Payout *index.Payout `json:"payout,omitempty"`
}
type VersionInfo struct {

View file

@ -11,6 +11,7 @@ require (
git.gammaspectra.live/P2Pool/p2pool-observer v0.0.0
git.gammaspectra.live/P2Pool/p2pool-observer/cmd/index v0.0.0
github.com/floatdrop/lru v1.3.0
github.com/goccy/go-json v0.10.2
)
require (
@ -22,7 +23,6 @@ require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/dolthub/maphash v0.1.0 // indirect
github.com/dolthub/swiss v0.1.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/holiman/uint256 v1.2.3 // indirect
github.com/jxskiss/base62 v1.1.0 // indirect
github.com/lib/pq v1.10.9 // indirect

View file

@ -16,6 +16,7 @@ type SignedActionEntry struct {
type SignedAction struct {
Action string `json:"action"`
Data []SignedActionEntry `json:"data"`
Realm string `json:"realm"`
}
func (a *SignedAction) Get(key string) (string, bool) {
@ -44,12 +45,25 @@ func (a *SignedAction) String() string {
return buf.String()
}
func (a *SignedAction) Verify(addr address.Interface, signature string) address.SignatureVerifyResult {
func (a *SignedAction) Verify(realm string, addr address.Interface, signature string) address.SignatureVerifyResult {
if a.Realm != realm {
// Realm does not match
return address.ResultFail
}
message := a.String()
return address.VerifyMessage(addr, []byte(message), signature)
}
func SignedActionSetMinerAlias(alias string) *SignedAction {
func (a *SignedAction) VerifyFallbackToZero(realm string, addr address.Interface, signature string) address.SignatureVerifyResult {
if a.Realm != realm {
// Realm does not match
return address.ResultFail
}
message := a.String()
return address.VerifyMessageFallbackToZero(addr, []byte(message), signature)
}
func SignedActionSetMinerAlias(realm, alias string) *SignedAction {
return &SignedAction{
Action: "set_miner_alias",
Data: []SignedActionEntry{
@ -58,17 +72,19 @@ func SignedActionSetMinerAlias(alias string) *SignedAction {
Value: alias,
},
},
Realm: realm,
}
}
func SignedActionUnsetMinerAlias() *SignedAction {
func SignedActionUnsetMinerAlias(realm string) *SignedAction {
return &SignedAction{
Action: "unset_miner_alias",
Data: make([]SignedActionEntry, 0),
Realm: realm,
}
}
func SignedActionAddWebHook(webhookType, webhookUrl string, other ...SignedActionEntry) *SignedAction {
func SignedActionAddWebHook(realm, webhookType, webhookUrl string, other ...SignedActionEntry) *SignedAction {
return &SignedAction{
Action: "add_webhook",
Data: append([]SignedActionEntry{
@ -81,10 +97,11 @@ func SignedActionAddWebHook(webhookType, webhookUrl string, other ...SignedActio
Value: webhookUrl,
},
}, other...),
Realm: realm,
}
}
func SignedActionRemoveWebHook(webhookType, webhookUrl string, other ...SignedActionEntry) *SignedAction {
func SignedActionRemoveWebHook(realm, webhookType, webhookUrlHash string, other ...SignedActionEntry) *SignedAction {
return &SignedAction{
Action: "remove_webhook",
Data: append([]SignedActionEntry{
@ -93,9 +110,10 @@ func SignedActionRemoveWebHook(webhookType, webhookUrl string, other ...SignedAc
Value: webhookType,
},
{
Key: "url",
Value: webhookUrl,
Key: "url_hash",
Value: webhookUrlHash,
},
}, other...),
Realm: realm,
}
}

401
cmd/utils/webhook.go Normal file
View file

@ -0,0 +1,401 @@
package utils
import (
"bytes"
"errors"
"fmt"
"git.gammaspectra.live/P2Pool/p2pool-observer/cmd/index"
"git.gammaspectra.live/P2Pool/p2pool-observer/monero/address"
"git.gammaspectra.live/P2Pool/p2pool-observer/p2pool/types"
"git.gammaspectra.live/P2Pool/p2pool-observer/utils"
"io"
"net/http"
"net/url"
"os"
"strconv"
"time"
)
type JSONWebHookDiscord struct {
Username string `json:"username,omitempty"`
AvatarUrl string `json:"avatar_url,omitempty"`
Content string `json:"content,omitempty"`
Embeds []JSONWebHookDiscordEmbed `json:"embeds,omitempty"`
Components []JSONWebHookDiscordComponentRow `json:"components,omitempty"`
TTS bool `json:"tts,omitempty"`
AllowedMentions *struct {
Parse []string `json:"parse,omitempty"`
Users []string `json:"users,omitempty"`
Roles []string `json:"roles,omitempty"`
} `json:"allowed_mentions,omitempty"`
}
func NewJSONWebHookDiscordComponent(b ...JSONWebHookDiscordComponentButton) JSONWebHookDiscordComponentRow {
return JSONWebHookDiscordComponentRow{
Type: 1,
Components: b,
}
}
func NewJSONWebHookDiscordComponentButton(label, url string) JSONWebHookDiscordComponentButton {
return JSONWebHookDiscordComponentButton{
Type: 2,
Style: 5,
Label: label,
Url: url,
}
}
type JSONWebHookDiscordComponentRow struct {
Type int `json:"type"`
Components []JSONWebHookDiscordComponentButton `json:"components"`
}
type JSONWebHookDiscordComponentButton struct {
Type int `json:"type"`
Style int `json:"style"`
Label string `json:"label,omitempty"`
Url string `json:"url,omitempty"`
}
type JSONWebHookDiscordEmbed struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Url string `json:"url,omitempty"`
// Timestamp Time in time.RFC3339 YYYY-MM-DDTHH:MM:SS.MSSZ format
Timestamp string `json:"timestamp,omitempty"`
Color *uint64 `json:"color,omitempty"`
Footer *struct {
Text string `json:"text,omitempty"`
} `json:"footer,omitempty"`
Image *struct {
Url string `json:"url,omitempty"`
} `json:"image,omitempty"`
Thumbnail *struct {
Url string `json:"url,omitempty"`
} `json:"thumbnail,omitempty"`
Provider *struct {
Name string `json:"name,omitempty"`
IconUrl string `json:"icon_url,omitempty"`
} `json:"provider,omitempty"`
Author *struct {
Name string `json:"name,omitempty"`
Url string `json:"url,omitempty"`
IconUrl string `json:"icon_url,omitempty"`
} `json:"author,omitempty"`
Fields []JSONWebHookDiscordEmbedField `json:"fields,omitempty"`
}
type JSONWebHookDiscordEmbedField struct {
Name string `json:"name,omitempty"`
Value string `json:"value,omitempty"`
Inline bool `json:"inline,omitempty"`
}
var (
DiscordColorGreen uint64 = 5763719
DiscordColorDarkGreen uint64 = 2067276
DiscordColorOrange uint64 = 15105570
)
var WebHookRateLimit = time.NewTicker(time.Second / 20)
var WebHookUserAgent string
var WebHookHost string
var WebHookVersion = fmt.Sprintf("%d.%d", types.CurrentSoftwareVersion.Major(), types.CurrentSoftwareVersion.Minor())
func init() {
WebHookHost = os.Getenv("NET_SERVICE_ADDRESS")
WebHookUserAgent = fmt.Sprintf("Mozilla/5.0 (compatible;GoObserver %s; +https://%s/api#webhooks)", types.CurrentSoftwareVersion.String(), WebHookHost)
}
var WebHookClient = http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
func SetWebHookProxy(hostPort string) {
WebHookClient.Transport = &http.Transport{
Proxy: http.ProxyURL(&url.URL{
Scheme: "socks5",
Host: hostPort,
}),
}
}
func SendSideBlock(w *index.MinerWebHook, ts int64, source *address.Address, block *index.SideBlock) error {
if !CanSendToHook(w, "side_blocks") {
return nil
}
blockValuation := func() string {
if block.IsOrphan() {
return "0%"
} else if block.IsUncle() {
return strconv.FormatUint(100-w.Consensus.UnclePenalty, 10) + "% (uncle)"
} else if block.IsUncle() {
return "100% + " + strconv.FormatUint(w.Consensus.UnclePenalty, 10) + "% of " + strconv.FormatUint(uint64(len(block.Uncles)), 10) + "% uncle(s)"
} else {
return "100%"
}
}
blockWeight, _ := block.Weight(block.EffectiveHeight, w.Consensus.ChainWindowSize, w.Consensus.UnclePenalty)
switch w.Type {
case index.WebHookDiscord:
var color = &DiscordColorGreen
fields := []JSONWebHookDiscordEmbedField{
{
Name: "Pool Height",
Value: strconv.FormatUint(block.SideHeight, 10),
Inline: true,
},
}
if block.IsUncle() {
color = &DiscordColorDarkGreen
fields = append(fields, JSONWebHookDiscordEmbedField{
Name: "Parent Height",
Value: "[" + strconv.FormatUint(block.EffectiveHeight, 10) + "](" + "https://" + WebHookHost + "/share/" + block.UncleOf.String() + ")",
Inline: true,
})
} else {
fields = append(fields, JSONWebHookDiscordEmbedField{
Name: "Monero Height",
Value: strconv.FormatUint(block.MainHeight, 10),
Inline: true,
})
}
fields = append(fields,
JSONWebHookDiscordEmbedField{
Name: "Found by",
Value: "[`" + string(utils.ShortenSlice(block.MinerAddress.ToBase58(), 24)) + "`](" + "https://" + WebHookHost + "/miner/" + string(block.MinerAddress.ToBase58()) + ")",
},
JSONWebHookDiscordEmbedField{
Name: "Template Id",
Value: "[`" + block.TemplateId.String() + "`](" + "https://" + WebHookHost + "/share/" + block.TemplateId.String() + ")",
},
JSONWebHookDiscordEmbedField{
Name: "Valuation",
Value: blockValuation(),
Inline: true,
},
JSONWebHookDiscordEmbedField{
Name: "Weight",
Value: utils.SiUnits(float64(blockWeight), 4),
Inline: true,
},
)
return SendJsonPost(w, ts, source, &JSONWebHookDiscord{
Embeds: []JSONWebHookDiscordEmbed{
{
Color: color,
Title: "**" + func() string {
if block.IsUncle() {
return "UNCLE "
} else {
return ""
}
}() + "SHARE FOUND** on P2Pool " + func() string {
if w.Consensus.IsDefault() {
return "Main"
} else if w.Consensus.IsMini() {
return "Mini"
} else {
return "Unknown"
}
}(),
Url: "https://" + WebHookHost + "/share/" + block.MainId.String(),
Description: "",
Fields: fields,
Footer: &struct {
Text string `json:"text,omitempty"`
}{
Text: "Share mined using " + block.SoftwareId.String() + " " + block.SoftwareVersion.String(),
},
Timestamp: time.Unix(int64(block.Timestamp), 0).UTC().Format(time.RFC3339),
},
},
})
case index.WebHookCustom:
return SendJsonPost(w, ts, source, &JSONEvent{
Type: JSONEventSideBlock,
SideBlock: block,
})
default:
return errors.New("unsupported hook type")
}
}
func SendFoundBlock(w *index.MinerWebHook, ts int64, source *address.Address, block *index.FoundBlock, outputs index.MainCoinbaseOutputs) error {
if !CanSendToHook(w, "found_blocks") {
return nil
}
switch w.Type {
case index.WebHookCustom:
return SendJsonPost(w, ts, source, &JSONEvent{
Type: JSONEventFoundBlock,
FoundBlock: block,
MainCoinbaseOutputs: outputs,
})
default:
return errors.New("unsupported hook type")
}
}
func CanSendToHook(w *index.MinerWebHook, key string) bool {
if v, ok := w.Settings["send_"+key]; ok && v == "false" {
return false
}
return true
}
func SendPayout(w *index.MinerWebHook, ts int64, source *address.Address, payout *index.Payout) error {
if !CanSendToHook(w, "payouts") {
return nil
}
switch w.Type {
case index.WebHookDiscord:
return SendJsonPost(w, ts, source, &JSONWebHookDiscord{
Embeds: []JSONWebHookDiscordEmbed{
{
Color: &DiscordColorOrange,
Title: "**RECEIVED PAYOUT** on P2Pool " + func() string {
if w.Consensus.IsDefault() {
return "Main"
} else if w.Consensus.IsMini() {
return "Mini"
} else {
return "Unknown"
}
}(),
Url: "https://" + WebHookHost + "/proof/" + payout.MainId.String() + "/" + strconv.FormatUint(payout.Index, 10),
Description: "",
Fields: []JSONWebHookDiscordEmbedField{
{
Name: "Pool Height",
Value: strconv.FormatUint(payout.SideHeight, 10),
Inline: true,
},
{
Name: "Monero Height",
Value: strconv.FormatUint(payout.MainHeight, 10),
Inline: true,
},
{
Name: "Monero Id",
Value: "[`" + payout.MainId.String() + "`](" + GetSiteUrl(SiteKeyP2PoolIo, false) + "/explorer/block/" + payout.MainId.String() + ")",
},
{
Name: "Template Id",
Value: "[`" + payout.TemplateId.String() + "`](" + "https://" + WebHookHost + "/share/" + payout.TemplateId.String() + ")",
},
{
Name: "Coinbase Id",
Value: "[`" + payout.CoinbaseId.String() + "`](" + GetSiteUrl(SiteKeyP2PoolIo, false) + "/explorer/tx/" + payout.CoinbaseId.String() + ")",
},
{
Name: "Coinbase Private Key",
Value: "[`" + payout.PrivateKey.String() + "`](" + "https://" + WebHookHost + "/proof/" + payout.MainId.String() + "/" + strconv.FormatUint(payout.Index, 10) + ")",
},
{
Name: "Payout address",
Value: "[`" + string(utils.ShortenSlice(source.ToBase58(), 24)) + "`](" + "https://" + WebHookHost + "/miner/" + string(source.ToBase58()) + ")",
},
{
Name: "Reward",
Value: "**" + utils.XMRUnits(payout.Reward) + " XMR" + "**",
Inline: true,
},
{
Name: "Global Output Index",
Value: strconv.FormatUint(payout.GlobalOutputIndex, 10),
Inline: true,
},
},
Timestamp: time.Unix(int64(payout.Timestamp), 0).UTC().Format(time.RFC3339),
},
},
})
case index.WebHookCustom:
return SendJsonPost(w, ts, source, &JSONEvent{
Type: JSONEventPayout,
Payout: payout,
})
default:
return errors.New("unsupported hook type")
}
}
func SendOrphanedBlock(w *index.MinerWebHook, ts int64, source *address.Address, block *index.SideBlock) error {
if !CanSendToHook(w, "orphaned_blocks") {
return nil
}
switch w.Type {
case index.WebHookCustom:
return SendJsonPost(w, ts, source, &JSONEvent{
Type: JSONEventOrphanedBlock,
SideBlock: block,
})
default:
return errors.New("unsupported hook type")
}
}
func SendJsonPost(w *index.MinerWebHook, ts int64, source *address.Address, data any) error {
uri, err := url.Parse(w.Url)
if err != nil {
return err
}
body, err := utils.MarshalJSON(data)
if err != nil {
return err
}
headers := make(http.Header)
headers.Set("Accept", "*/*")
headers.Set("User-Agent", WebHookUserAgent)
headers.Set("Content-Type", "application/json")
headers.Set("X-P2Pool-Observer-Timestamp", strconv.FormatInt(ts, 10))
headers.Set("X-P2Pool-Observer-Version", WebHookVersion)
headers.Set("X-P2Pool-Observer-Host", WebHookHost)
headers.Set("X-P2Pool-Observer-Consensus-ID", w.Consensus.Id.String())
headers.Set("X-P2Pool-Observer-Address", string(source.ToBase58()))
// apply rate limit
<-WebHookRateLimit.C
response, err := WebHookClient.Do(&http.Request{
Method: "POST",
URL: uri,
Header: headers,
Body: io.NopCloser(bytes.NewBuffer(body)),
ContentLength: int64(len(body)),
})
if err != nil {
return err
}
defer response.Body.Close()
defer io.ReadAll(response.Body)
if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusNoContent {
data, _ := io.ReadAll(response.Body)
return fmt.Errorf("error code %d: %s", response.StatusCode, string(data))
}
return nil
}

View file

@ -25,6 +25,7 @@ func getTypeFromAPI[T any](method string, cacheTime ...int) *T {
return nil
} else {
defer response.Body.Close()
defer io.ReadAll(response.Body)
if response.StatusCode == http.StatusOK {
var result T
decoder := utils.NewJSONDecoder(response.Body)
@ -55,6 +56,7 @@ func getSliceFromAPI[T any](method string, cacheTime ...int) []T {
return nil
} else {
defer response.Body.Close()
defer io.ReadAll(response.Body)
if response.StatusCode == http.StatusOK {
var result []T
decoder := utils.NewJSONDecoder(response.Body)
@ -141,6 +143,7 @@ func getFromAPIRaw(method string, cacheTime ...int) []byte {
return nil
} else {
defer response.Body.Close()
defer io.ReadAll(response.Body)
if response.StatusCode == http.StatusOK {
if data, err := io.ReadAll(response.Body); err != nil {
return nil
@ -153,3 +156,22 @@ func getFromAPIRaw(method string, cacheTime ...int) []byte {
}
})
}
func getFromAPI(method string) (statusCode int, data []byte) {
uri, _ := url.Parse(os.Getenv("API_URL") + method)
if response, err := http.DefaultClient.Do(&http.Request{
Method: "GET",
URL: uri,
}); err != nil {
return 0, nil
} else {
defer response.Body.Close()
defer io.ReadAll(response.Body)
if data, err = io.ReadAll(response.Body); err != nil {
return response.StatusCode, nil
} else {
return response.StatusCode, data
}
}
}

View file

@ -12,6 +12,7 @@ require (
git.gammaspectra.live/P2Pool/p2pool-observer v0.0.0
git.gammaspectra.live/P2Pool/p2pool-observer/cmd/index v0.0.0
git.gammaspectra.live/P2Pool/p2pool-observer/cmd/utils v0.0.0
github.com/goccy/go-json v0.10.2
github.com/gorilla/mux v1.8.0
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc
github.com/valyala/quicktemplate v1.7.0
@ -28,7 +29,6 @@ require (
github.com/dolthub/maphash v0.1.0 // indirect
github.com/dolthub/swiss v0.1.0 // indirect
github.com/floatdrop/lru v1.3.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/holiman/uint256 v1.2.3 // indirect
github.com/jxskiss/base62 v1.1.0 // indirect
github.com/lib/pq v1.10.9 // indirect

View file

@ -0,0 +1,183 @@
{% import "git.gammaspectra.live/P2Pool/p2pool-observer/cmd/index" %}
{% import cmdutils "git.gammaspectra.live/P2Pool/p2pool-observer/cmd/utils" %}
{% code
type MinerOptionsPage struct {
// inherit from base page, so its' title is used in error page.
BasePage
Miner *cmdutils.MinerInfoResult
SignedAction *cmdutils.SignedAction
WebHooks []*index.MinerWebHook
}
%}
{% func (p *MinerOptionsPage) Title() %}
{%= p.BasePage.Title() %} - Miner Options {%z= p.Miner.Address.ToBase58() %}
{% endfunc %}
{% func (p *MinerOptionsPage) Content() %}
<div style="text-align: center">
<h2 class="mono">Miner Options</h2>
<p><strong>Payout Address:</strong> <span class="mono small">{%z= p.Miner.Address.ToBase58() %}</span></p>
{% if p.Miner.Alias != "" %}
<p><strong>Miner Alias:</strong> <span class="mono">{%s p.Miner.Alias %}</span></p>
<p><small>Miner Alias is user content and not verified. This value should only be used for vanity purposes.</small></p>
{% endif %}
</div>
{% if p.SignedAction != nil %}
<div style="text-align: center;">
<form action="/miner-options/{%z= p.Miner.Address.ToBase58() %}/signed_action" method="get" target="_blank">
<h2>Sign and submit changes</h2>
<p>To submit any changes you will need to sign a message using your wallet to prove ownership.</p>
<p>On the Monero GUI, go to Advanced -> Sign/verify -> Select Mode "Message".</p>
<p>Enter the Message listed below in the "Message" field, then press Sign Message. Copy the Signature and paste it on the field below.</p>
<p>Do not modify the message, add spaces or add any new lines.</p>
<p><small>Signatures generated by a View-Only wallet are not supported. Monero GUI does not generate valid signatures for hardware wallets.</small></p>
<div>
<label for="message">Message</label><br/>
<input type="text" name="message" id="message" size="96" class="mono" value="{%s p.SignedAction.String() %}" style="user-select: all; -webkit-user-select: all;" readonly autofocus/>
</div>
<div style="margin-top: 10px">
<label for="signature">Signature</label><br/>
<input type="text" name="signature" id="signature" size="96" class="mono" placeholder="SigV2..." pattern="^SigV[12][123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$" required/>
</div>
<div style="margin-top: 10px">
<input type="submit" value="Submit changes" style="width: 20em;"/>
</div>
</form>
</div>
{% else %}
<div style="text-align: center;">
<h2 id="mineralias">Miner Alias</h2>
<div>
<form action="/miner-options/{%z= p.Miner.Address.ToBase58() %}/set_miner_alias" method="get" target="_blank">
<h3>Set Miner Alias</h3>
<p><small>
Miner Alias must be 4 to 20 characters long, and only 0-9, a-z, A-Z, and _-. are allowed, and cannot start with a number or symbol. No spaces are allowed either.
</br>
Alias are unique for the observer instance, and can be removed or changed after adding one.
</small></p>
<div>
<label for="miner-alias">Miner Alias</label><br/>
<input type="text" name="alias" id="miner-alias" minlength="4" maxlength="20" size="20" class="mono" value="{%s p.Miner.Alias %}" pattern="^[a-zA-Z][0-9a-zA-Z_.\-]+$" required/>
</div>
<div style="margin-top: 10px">
<input type="submit" value="Set Alias" style="width: 20em;"/>
</div>
</form>
</div>
<div>
{% if p.Miner.Alias != "" %}
<form action="/miner-options/{%z= p.Miner.Address.ToBase58() %}/unset_miner_alias" method="get" target="_blank">
<h3>Unset Miner Alias</h3>
<p><small>You can remove your miner alias below if you don't want one set.</small></p>
<div style="margin-top: 10px">
<input type="submit" value="Unset Alias" style="width: 20em;"/>
</div>
</form>
{% endif %}
</div>
</div>
<div style="text-align: center;">
<h2 id="webhooks">Notification WebHooks</h2>
<div>
<form action="/miner-options/{%z= p.Miner.Address.ToBase58() %}/add_webhook" method="get" target="_blank">
<h3>Add WebHook</h3>
<p><small>
Configure notification URLs that can be used to post messages or other actions in other platforms or services.
</br>
Only one URL of each type is allowed. To adjust an existing hook settings you will need to add it with the new preference.
</small></p>
<div>
<label for="webhook-type">WebHook Type</label><br/>
<select type="text" name="type" id="webhook-type" required>
<option value=""></option>
<optgroup label="Chat Services">
<option value="{%s string(index.WebHookDiscord) %}">Discord</option>
<option value="{%s string(index.WebHookSlack) %}" disabled>Slack (coming soon)</option>
<option value="{%s string(index.WebHookTelegram) %}" disabled>Telegram (coming soon)</option>
<option value="{%s string(index.WebHookMatrixHookshot) %}" disabled>Matrix Hookshot (coming soon)</option>
</optgroup>
<optgroup label="Other">
<option value="{%s string(index.WebHookCustom) %}">Custom</option>
</optgroup>
</select>
</div>
</br>
<div>
<label for="webhook-url">WebHook URL</label><br/>
<input type="url" name="url" id="webhook-url" size="60" class="mono" value="" required/>
</div>
</br>
<fieldset>
<legend>WebHook Events</legend>
<div>
<input type="checkbox" id="webhook-side_blocks" name="side_blocks" checked />
<label for="webhook-side_blocks">Miner Side blocks</label>
</div>
<div>
<input type="checkbox" id="webhook-payouts" name="payouts" checked />
<label for="webhook-payouts">Miner Payouts</label>
</div>
<div>
<input type="checkbox" id="webhook-found_blocks" name="found_blocks" />
<label for="webhook-found_blocks">Found Monero blocks</label>
</div>
<div>
<input type="checkbox" id="webhook-orphaned_blocks" name="orphaned_blocks" />
<label for="webhook-orphaned_blocks">Orphaned Monero blocks</label>
</div>
<div>
<input type="checkbox" id="webhook-other" name="other" checked />
<label for="webhook-other">Other (notices, updates, events)</label>
</div>
</fieldset>
<div style="margin-top: 10px">
<input type="submit" value="Add WebHook" style="width: 20em;"/>
</div>
</form>
{% if len(p.WebHooks) > 0 %}
<h3>Current WebHooks</h3>
<table class="center datatable" style="max-width: calc(8em + 28em + 8em + 8em + 8em + 8em + 8em + 8em)">
<tr>
<th style="width: 8em;">Type</th>
<th style="width: 28em;" title="URL SHA-256 Hash">URL Hash</th>
<th style="width: 8em;">Shares</th>
<th style="width: 8em;">Payouts</th>
<th style="width: 8em;">Found Blocks</th>
<th style="width: 8em;">Orphaned Blocks</th>
<th style="width: 8em;">Other</th>
<th style="width: 8em;"></th>
</tr>
{% for _, w := range p.WebHooks %}
<tr>
<th>{%s string(w.Type) %}</th>
<td class="mono small">{%s w.Url %}</td>
<td>{% if cmdutils.CanSendToHook(w, "side_blocks") %}Yes{% else %}No{% endif %}</td>
<td>{% if cmdutils.CanSendToHook(w, "payouts") %}Yes{% else %}No{% endif %}</td>
<td>{% if cmdutils.CanSendToHook(w, "found_blocks") %}Yes{% else %}No{% endif %}</td>
<td>{% if cmdutils.CanSendToHook(w, "orphaned_blocks") %}Yes{% else %}No{% endif %}</td>
<td>{% if cmdutils.CanSendToHook(w, "other") %}Yes{% else %}No{% endif %}</td>
<td><a href="/miner-options/{%z= p.Miner.Address.ToBase58() %}/remove_webhook?type={%s string(w.Type) %}&url_hash={%s w.Url %}">[Remove]</a></td>
</tr>
{% endfor %}
</table>
{% endif %}
</div>
</div>
{% endif %}
{% endfunc %}

View file

@ -128,6 +128,8 @@ type MinerPage struct {
<p><small>Miner Alias is user content and not verified. This value should only be used for vanity purposes.</small></p>
{% endif %}
<p><small><a href="/miner-options/{%z= p.Miner.Address.ToBase58() %}#mineralias">[Change Miner Alias]</a> :: <a href="/miner-options/{%z= p.Miner.Address.ToBase58() %}#webhooks">[Configure WebHook notifications]</a></small></p>
<table class="center" style="max-width: calc(15em + 15em + 15em + 15em + 15em)">
<tr>
<th>Last Share</th>
@ -271,28 +273,5 @@ type MinerPage struct {
<input type="submit" value="Calculate" style="width: 20em; margin: 20px;"/>
</form>
</div>
<hr/>
<div style="text-align: center">
<form action="/api/miner_alias/{%z= p.Miner.Address.ToBase58() %}" method="get" target="_blank">
<h3>Set Miner Alias</h3>
<p>To set a miner alias you will need to sign a message using your wallet. On the Monero GUI, go to Advanced -> Sign/verify -> Select Mode "Message"</p>
<p>Enter the desired Miner Alias in Message, then press Sign Message. Copy the Signature and paste it on the field below, along with your chosen Miner Alias.</p>
<p><small>Miner Alias must be 4 to 20 characters long, and only 0-9, a-z, A-Z, and _-. are allowed, and cannot start with a number or symbol. No spaces are allowed either. Alias are unique for the observer instance.</small></p>
<p><small>Signatures generated by a View-Only wallet are not supported. Monero GUI does not generate valid signatures for hardware wallets.</small></p>
<p><small>You can remove your miner alias by using "REMOVE_MINER_ALIAS" as an alias.</small></p>
<div>
<label for="miner-alias">Miner Alias</label><br/>
<input type="text" name="message" id="miner-alias" size="20" class="mono"/>
</div>
<div>
<label for="signature">Signature</label><br/>
<input type="text" name="signature" id="signature" size="96" class="mono"/>
</div>
<div style="margin-top: 10px">
<input type="submit" value="Set Alias" style="width: 20em;"/>
</div>
</form>
</div>
{% endif %}
{% endfunc %}

View file

@ -14,6 +14,7 @@ import (
types2 "git.gammaspectra.live/P2Pool/p2pool-observer/p2pool/types"
"git.gammaspectra.live/P2Pool/p2pool-observer/types"
"git.gammaspectra.live/P2Pool/p2pool-observer/utils"
"github.com/goccy/go-json"
"github.com/gorilla/mux"
"github.com/valyala/quicktemplate"
"io"
@ -153,19 +154,23 @@ func main() {
switch strings.Split(humanHost, ":")[0] {
case "irc.libera.chat":
if len(splitChan) > 1 {
matrixLink = fmt.Sprintf("https://matrix.to/#/#%s:%s", splitChan[0], splitChan[1])
matrixLink = fmt.Sprintf("https://matrix.to/#/#%s:%s?via=matrix.org&via=%s", splitChan[0], splitChan[1], splitChan[1])
webchatLink = fmt.Sprintf("https://web.libera.chat/?nick=Guest?#%s", splitChan[0])
} else {
humanHost = "libera.chat"
matrixLink = fmt.Sprintf("https://matrix.to/#/#%s:%s", ircUrl.Fragment, humanHost)
matrixLink = fmt.Sprintf("https://matrix.to/#/#%s:%s?via=matrix.org", ircUrl.Fragment, humanHost)
webchatLink = fmt.Sprintf("https://web.libera.chat/?nick=Guest%%3F#%s", ircUrl.Fragment)
}
case "irc.hackint.org":
if len(splitChan) > 1 {
matrixLink = fmt.Sprintf("https://matrix.to/#/#%s:%s", splitChan[0], splitChan[1])
matrixLink = fmt.Sprintf("https://matrix.to/#/#%s:%s?via=matrix.org&via=%s", splitChan[0], splitChan[1], splitChan[1])
} else {
humanHost = "hackint.org"
matrixLink = fmt.Sprintf("https://matrix.to/#/#%s:%s", ircUrl.Fragment, humanHost)
matrixLink = fmt.Sprintf("https://matrix.to/#/#%s:%s?via=matrix.org", ircUrl.Fragment, humanHost)
}
default:
if len(splitChan) > 1 {
matrixLink = fmt.Sprintf("https://matrix.to/#/#%s:%s?via=matrix.org&via=%s", splitChan[0], splitChan[1], splitChan[1])
}
}
ircLinkTitle = fmt.Sprintf("#%s@%s", splitChan[0], humanHost)
@ -1063,6 +1068,99 @@ func main() {
renderPage(request, writer, minerPage, poolInfo)
})
serveMux.HandleFunc("/miner-options/{miner:[^ ]+}/signed_action", func(writer http.ResponseWriter, request *http.Request) {
params := request.URL.Query()
address := mux.Vars(request)["miner"]
miner := getTypeFromAPI[cmdutils.MinerInfoResult](fmt.Sprintf("miner_info/%s?noShares", address))
if miner == nil || miner.Address == nil {
renderPage(request, writer, views.NewErrorPage(http.StatusNotFound, "Address Not Found", "You need to have mined at least one share in the past. Come back later :)"))
return
}
var signedAction *cmdutils.SignedAction
if err := json.Unmarshal([]byte(params.Get("message")), &signedAction); err != nil || signedAction == nil {
renderPage(request, writer, views.NewErrorPage(http.StatusBadRequest, "Invalid Message", nil))
return
}
values := make(url.Values)
values.Set("signature", params.Get("signature"))
values.Set("message", signedAction.String())
var jsonErr struct {
Error string `json:"error"`
}
statusCode, buf := getFromAPI("miner_signed_action/" + string(miner.Address.ToBase58()) + "?" + values.Encode())
_ = json.Unmarshal(buf, &jsonErr)
if statusCode != http.StatusOK {
renderPage(request, writer, views.NewErrorPage(http.StatusBadRequest, "Could not verify message", jsonErr.Error))
return
}
})
serveMux.HandleFunc("/miner-options/{miner:[^ ]+}/{action:set_miner_alias|unset_miner_alias|add_webhook|remove_webhook}", func(writer http.ResponseWriter, request *http.Request) {
params := request.URL.Query()
address := mux.Vars(request)["miner"]
miner := getTypeFromAPI[cmdutils.MinerInfoResult](fmt.Sprintf("miner_info/%s?noShares", address))
if miner == nil || miner.Address == nil {
renderPage(request, writer, views.NewErrorPage(http.StatusNotFound, "Address Not Found", "You need to have mined at least one share in the past. Come back later :)"))
return
}
var signedAction *cmdutils.SignedAction
action := mux.Vars(request)["action"]
switch action {
case "set_miner_alias":
signedAction = cmdutils.SignedActionSetMinerAlias(baseContext.NetServiceAddress, params.Get("alias"))
case "unset_miner_alias":
signedAction = cmdutils.SignedActionUnsetMinerAlias(baseContext.NetServiceAddress)
case "add_webhook":
signedAction = cmdutils.SignedActionAddWebHook(baseContext.NetServiceAddress, params.Get("type"), params.Get("url"))
for _, s := range []string{"side_blocks", "payouts", "found_blocks", "orphaned_blocks", "other"} {
value := "false"
if params.Get(s) == "on" {
value = "true"
}
signedAction.Data = append(signedAction.Data, cmdutils.SignedActionEntry{
Key: "send_" + s,
Value: value,
})
}
case "remove_webhook":
signedAction = cmdutils.SignedActionRemoveWebHook(baseContext.NetServiceAddress, params.Get("type"), params.Get("url_hash"))
default:
renderPage(request, writer, views.NewErrorPage(http.StatusNotFound, "Invalid Action", nil))
return
}
renderPage(request, writer, &views.MinerOptionsPage{
Miner: miner,
SignedAction: signedAction,
WebHooks: getSliceFromAPI[*index.MinerWebHook](fmt.Sprintf("miner_webhooks/%s", address)),
})
})
serveMux.HandleFunc("/miner-options/{miner:[^ ]+}", func(writer http.ResponseWriter, request *http.Request) {
//params := request.URL.Query()
address := mux.Vars(request)["miner"]
miner := getTypeFromAPI[cmdutils.MinerInfoResult](fmt.Sprintf("miner_info/%s?noShares", address))
if miner == nil || miner.Address == nil {
renderPage(request, writer, views.NewErrorPage(http.StatusNotFound, "Address Not Found", "You need to have mined at least one share in the past. Come back later :)"))
return
}
renderPage(request, writer, &views.MinerOptionsPage{
Miner: miner,
SignedAction: nil,
WebHooks: getSliceFromAPI[*index.MinerWebHook](fmt.Sprintf("miner_webhooks/%s", address)),
})
})
serveMux.HandleFunc("/miner", func(writer http.ResponseWriter, request *http.Request) {
params := request.URL.Query()
if params.Get("address") == "" {

View file

@ -31,6 +31,15 @@ services:
- p2pool
networks:
- p2pool-observer
tor-proxy:
image: goldy/tor-hidden-service:v0.4.7.12-54c0e54
tmpfs:
- /tmp
restart: always
environment:
TOR_SOCKS_PORT: 9050
networks:
- p2pool-observer
site:
build:
context: ./docker/nginx
@ -93,7 +102,6 @@ services:
- /run
- /run/postgresql
p2pool:
build:
context: ./
@ -132,6 +140,7 @@ services:
- GOPROXY=${GOPROXY}
restart: always
environment:
- NET_SERVICE_ADDRESS=${NET_SERVICE_ADDRESS}
- TOR_SERVICE_ADDRESS=${TOR_SERVICE_ADDRESS}
- TRANSACTION_LOOKUP_OTHER=${TRANSACTION_LOOKUP_OTHER}
depends_on:
@ -185,9 +194,11 @@ services:
restart: always
environment:
- TOR_SERVICE_ADDRESS=${TOR_SERVICE_ADDRESS}
- NET_SERVICE_ADDRESS=${NET_SERVICE_ADDRESS}
- TRANSACTION_LOOKUP_OTHER=${TRANSACTION_LOOKUP_OTHER}
depends_on:
- db
- tor-proxy
- p2pool
security_opt:
- no-new-privileges:true
@ -201,6 +212,7 @@ services:
-host ${MONEROD_HOST}
-rpc-port ${MONEROD_RPC_PORT}
-api-host "http://p2pool:3131"
-hook-proxy "tor-proxy:9050"
-db="postgres:///p2pool?host=/var/run/postgresql&user=p2pool&password=p2pool&sslmode=disable"
pgo-p2pool:

View file

@ -92,7 +92,11 @@ func GetTxProofV1(a Interface, txId types.Hash, txKey crypto.PrivateKey, message
type SignatureVerifyResult int
const (
ResultFail SignatureVerifyResult = iota
ResultFailZeroSpend SignatureVerifyResult = -2
ResultFailZeroView SignatureVerifyResult = -1
)
const (
ResultFail = SignatureVerifyResult(iota)
ResultSuccessSpend
ResultSuccessView
)
@ -130,6 +134,11 @@ func VerifyMessage(a Interface, message []byte, signature string) SignatureVerif
return ResultSuccessSpend
}
// Special mode: view wallets in Monero GUI could generate signatures with spend public key proper, with message hash of spend wallet mode, but zero spend private key
if crypto.VerifyMessageSignatureSplit(hash, a.SpendPublicKey(), ZeroPrivateKeyAddress.SpendPublicKey(), sig) {
return ResultFailZeroSpend
}
if strings.HasPrefix(signature, "SigV2") {
hash = GetMessageHash(a, message, 1)
}
@ -140,3 +149,47 @@ func VerifyMessage(a Interface, message []byte, signature string) SignatureVerif
return ResultFail
}
// VerifyMessageFallbackToZero Check for Monero GUI behavior to generate wrong signatures on view-only wallets
func VerifyMessageFallbackToZero(a Interface, message []byte, signature string) SignatureVerifyResult {
var hash types.Hash
if strings.HasPrefix(signature, "SigV1") {
hash = crypto.Keccak256(message)
} else if strings.HasPrefix(signature, "SigV2") {
hash = GetMessageHash(a, message, 0)
} else {
return ResultFail
}
raw := moneroutil.DecodeMoneroBase58([]byte(signature[5:]))
sig := crypto.NewSignatureFromBytes(raw)
if sig == nil {
return ResultFail
}
if crypto.VerifyMessageSignature(hash, a.SpendPublicKey(), sig) {
return ResultSuccessSpend
}
// Special mode: view wallets in Monero GUI could generate signatures with spend public key proper, with message hash of spend wallet mode, but zero spend private key
if crypto.VerifyMessageSignatureSplit(hash, a.SpendPublicKey(), ZeroPrivateKeyAddress.SpendPublicKey(), sig) {
return ResultFailZeroSpend
}
if strings.HasPrefix(signature, "SigV2") {
hash = GetMessageHash(a, message, 1)
}
if crypto.VerifyMessageSignature(hash, a.ViewPublicKey(), sig) {
return ResultSuccessView
}
// Special mode
if crypto.VerifyMessageSignatureSplit(hash, a.ViewPublicKey(), ZeroPrivateKeyAddress.ViewPublicKey(), sig) {
return ResultFailZeroView
}
return ResultFail
}

View file

@ -80,3 +80,45 @@ func TestDerivePublicKey(t *testing.T) {
}
}
}
var testThrowawayAddress = FromBase58("42ecNLuoGtn1qC9SPSD9FPMNfsv35RE66Eu8WJJtyEHKfEsEiALVVp7GBCeAYFb7PHcSZmz9sDUtRMnKk2as1KfuLuTQJ3i")
var signatureMessage = []byte("test_message")
var signatureMessage2 = []byte("test_message2")
const (
SignatureSpendSuccess = "SigV2DnQYF11xZ1ahLJxddohCroiEJRnUe1tgwD5ksmFMzQ9NcRdbxLPrEdQW3e8w4sLpqhSup5tU9igQqeAR8j7r7Sty"
SignatureSpendWrongMessage = "SigV26RKRd31efizGHrWHwtYG6EN2MmwvF1rjU4ygZQuDmSxvCJnky1GJTzaM49naQeKvXbaGcnpZ1b3k8gVQLaFMFiBJ"
SignatureViewSuccess = "SigV2b7LaAuXrFvPAXwU11SJwHbcXJoKfQ5aBJ9FwMJNxvMTu78AebqNUCWPH1BVfNRvy1f3GCTLjHfWvuRJMZtSHu5uj"
SignatureViewWrongMessage = "SigV2AxWUATswZvnHSR5mMRsn9GcJe2gSCv3SbFwHv6J8THkj5KvmR8gUnTidHovZVyxgNHcUuiunM2dfVhbZvBTS6sZZ"
)
func TestSignature(t *testing.T) {
result := VerifyMessage(testThrowawayAddress, signatureMessage, SignatureSpendSuccess)
if result != ResultSuccessSpend {
t.Fatalf("unexpected %d", result)
}
result = VerifyMessage(testThrowawayAddress, signatureMessage, SignatureSpendWrongMessage)
if result != ResultFail {
t.Fatalf("unexpected %d", result)
}
result = VerifyMessage(testThrowawayAddress, signatureMessage2, SignatureSpendSuccess)
if result != ResultFail {
t.Fatalf("unexpected %d", result)
}
result = VerifyMessage(testThrowawayAddress, signatureMessage, SignatureViewSuccess)
if result != ResultFailZeroSpend {
t.Fatalf("unexpected %d", result)
}
result = VerifyMessage(testThrowawayAddress, signatureMessage, SignatureViewWrongMessage)
if result != ResultFail {
t.Fatalf("unexpected %d", result)
}
result = VerifyMessage(testThrowawayAddress, signatureMessage2, SignatureViewSuccess)
if result != ResultFail {
t.Fatalf("unexpected %d", result)
}
result = VerifyMessage(&ZeroPrivateKeyAddress, signatureMessage, SignatureViewSuccess)
if result != ResultFail {
t.Fatalf("unexpected %d", result)
}
}

View file

@ -80,13 +80,18 @@ func CreateMessageSignature(prefixHash types.Hash, key PrivateKey) *Signature {
}
func VerifyMessageSignature(prefixHash types.Hash, publicKey PublicKey, signature *Signature) bool {
return VerifyMessageSignatureSplit(prefixHash, publicKey, publicKey, signature)
}
// VerifyMessageSignatureSplit Allows specifying a different signer key than for the rest. Use VerifyMessageSignature in all other cases
func VerifyMessageSignatureSplit(prefixHash types.Hash, commPublicKey, signPublicKey PublicKey, signature *Signature) bool {
buf := &SignatureComm{}
buf.Hash = prefixHash
buf.Key = publicKey
buf.Key = commPublicKey
ok, _ := signature.Verify(func(r PublicKey) []byte {
buf.Comm = r
return buf.Bytes()
}, publicKey)
}, signPublicKey)
return ok
}