diff --git a/.golangci.yml b/.golangci.yml index 5ccca1f..d35df74 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -101,25 +101,10 @@ linters-settings: # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; # default is false: such cases aren't reported by default. check-blank: false - govet: - # report about shadowed variables - #TODO# check-shadowing: true - - # Obtain type information from installed (to $GOPATH/pkg) package files: - # golangci-lint will execute `go install -i` and `go test -i` for analyzed packages - # before analyzing them. - # Enable this option only if all conditions are met: - # 1. you use only "fast" linters (--fast e.g.): no program loading occurs - # 2. you use go >= 1.10 - # 3. you do repeated runs (false for CI) or cache $GOPATH/pkg or `go env GOCACHE` dir in CI. - use-installed-packages: false + govet: {} gocritic: disabled-checks: - ifElseChain - goimports: - local: "storj.io" - golint: - min-confidence: 0.8 gofmt: simplify: true gocyclo: @@ -129,26 +114,10 @@ linters-settings: goconst: min-len: 3 min-occurrences: 3 - misspell: + misspell: {} lll: line-length: 140 tab-width: 1 - unused: - # treat code as a program (not a library) and report unused exported identifiers; default is false. - # XXX: if you enable this setting, unused will report a lot of false-positives in text editors: - # if it's called for subdir of a project it can't find funcs usages. All text editor integrations - # with golangci-lint call it on a directory with the changed file. - check-exported: false - unparam: - # call graph construction algorithm (cha, rta). In general, use cha for libraries, - # and rta for programs with main packages. Default is cha. - algo: cha - - # Inspect exported functions, default is false. Set to true if no external program/library imports your code. - # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors: - # if it's called for subdir of a project it can't find external interfaces. All text editor integrations - # with golangci-lint call it on a directory with the changed file. - check-exported: false nakedret: # make an issue if func has more lines of code than this setting and it has naked returns; default is 30 max-func-lines: 30 diff --git a/cmd/crybapy/cmd_audit.go b/cmd/crybapy/cmd_audit.go index dcbcdb8..f8c95fd 100644 --- a/cmd/crybapy/cmd_audit.go +++ b/cmd/crybapy/cmd_audit.go @@ -86,7 +86,7 @@ func doAudit(config *auditConfig) error { fmt.Printf("Mismatched..................: %d\n", stats.Mismatched) if stats.DoublePays > 0 { fmt.Println(aurora.Red(fmt.Sprintf("Double Pays.................: %d", stats.DoublePays))) - fmt.Println(aurora.Red(fmt.Sprintf("Double Pay Amount (raw STORJ value): %s", stats.DoublePayStorj))) + fmt.Println(aurora.Red(fmt.Sprintf("Double Pay Amount (raw STORJ value): %s", stats.DoublePayStorj.String()))) bad = true } diff --git a/cmd/crybapy/cmd_payer_balance.go b/cmd/crybapy/cmd_payer_balance.go index 1ee61e4..a30a289 100644 --- a/cmd/crybapy/cmd_payer_balance.go +++ b/cmd/crybapy/cmd_payer_balance.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "github.com/spf13/cobra" ) @@ -27,7 +28,6 @@ func newPayerBalanceCommand(parentConfig *payerCommandConfig) *cobra.Command { } func doPayerBalance(config *payerBalanceConfig, spenderKeyPath string) error { - log, err := openConsoleLog() if err != nil { return err @@ -40,11 +40,7 @@ func doPayerBalance(config *payerBalanceConfig, spenderKeyPath string) error { if err != nil { return err } - dec, err := payer.GetTokenDecimals(config.Ctx) - if err != nil { - return err - } - fmt.Println(printToken(balance, dec, "")) + fmt.Println(printToken(balance, payer.Decimals(), "")) return nil } diff --git a/cmd/crybapy/cmd_payer_transfer.go b/cmd/crybapy/cmd_payer_transfer.go index 9a67d07..72e94a6 100644 --- a/cmd/crybapy/cmd_payer_transfer.go +++ b/cmd/crybapy/cmd_payer_transfer.go @@ -112,14 +112,14 @@ func doPayerTransfer(config *payerTransferConfig, spenderKeyPath string) error { LastUpdated: time.Now(), }, err }), - PipelineLimit: pipeline.DefaultLimit, TxDelay: pipeline.DefaultTxDelay, Drain: false, PromptConfirm: promptConfirm, }, db, - payer) + payer, + ) if err != nil { return err } diff --git a/cmd/crybapy/cmd_run.go b/cmd/crybapy/cmd_run.go index e06ce29..8b53978 100644 --- a/cmd/crybapy/cmd_run.go +++ b/cmd/crybapy/cmd_run.go @@ -8,6 +8,7 @@ import ( "storj.io/crypto-batch-payment/pkg/pipelinedb" + "github.com/shopspring/decimal" "github.com/spf13/cobra" "github.com/zeebo/errs" @@ -118,6 +119,11 @@ func doRun(config *runConfig) error { return err } + maxFeeUSD, err := parseOptionalMaxFeeUSD(config.MaxFeeUSD) + if err != nil { + return err + } + dbPath := payouts.DBPathFromDir(runDir) db, err := pipelinedb.OpenDB(context.Background(), dbPath, false) if err != nil { @@ -126,11 +132,12 @@ func doRun(config *runConfig) error { defer func() { _ = db.Close() }() payoutsConfig := payouts.Config{ - Quoter: quoter, - PipelineLimit: config.PipelineLimit, - TxDelay: config.TxDelay, - Drain: config.Drain, - PromptConfirm: promptConfirm, + Quoter: quoter, + PipelineLimit: config.PipelineLimit, + TxDelay: config.TxDelay, + Drain: config.Drain, + PromptConfirm: promptConfirm, + MaxFeeTolerationUSD: maxFeeUSD, } err = payouts.Preview(config.Ctx, payoutsConfig, db, payer) @@ -150,3 +157,13 @@ func doRun(config *runConfig) error { fmt.Println("Payouts complete.") return nil } + +func parseOptionalMaxFeeUSD(s string) (maxFeeUSD decimal.Decimal, err error) { + if s != "" { + maxFeeUSD, err = decimal.NewFromString(s) + if err != nil { + return decimal.Decimal{}, errs.New("invalid max fee value: %v", err) + } + } + return maxFeeUSD, nil +} diff --git a/cmd/crybapy/factory_payer.go b/cmd/crybapy/factory_payer.go index c9c25e5..2116ed9 100644 --- a/cmd/crybapy/factory_payer.go +++ b/cmd/crybapy/factory_payer.go @@ -2,11 +2,9 @@ package main import ( "context" - "math/big" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" - "github.com/ethereum/go-ethereum/params" "github.com/spf13/cobra" "github.com/zeebo/errs" "go.uber.org/zap" @@ -23,21 +21,13 @@ type PayerConfig struct { ContractAddress string Owner string - MaxGas string - MaxFee string - - GasTipCap string + MaxFeeUSD string PaymasterAddress string PaymasterPayload string } func RegisterFlags(cmd *cobra.Command, config *PayerConfig) { - cmd.Flags().StringVarP( - &config.GasTipCap, - "gas-tip-cap", "", - "1000000000", - "Gas tip cap, paid on top of the base gas.") cmd.Flags().StringVarP( &config.Owner, "owner", "", @@ -48,21 +38,16 @@ func RegisterFlags(cmd *cobra.Command, config *PayerConfig) { "contract", "", storjtoken.DefaultContractAddress.String(), "Address of the STORJ contract on the network") - cmd.Flags().StringVarP( - &config.MaxGas, - "max-gas", "", - "70"+"000"+"000"+"000", - "Max gas price we're willing to consider in Wei (tip + base fee). Default: 70 GWei. Only applies to Eth type payment.") cmd.Flags().StringVarP( &config.PayerType, "type", "", payer.Eth.String(), "Type of the payment (eth,zksync-era,zksync,zkwithdraw,sim,polygon)") cmd.Flags().StringVarP( - &config.MaxFee, - "max-fee", "", + &config.MaxFeeUSD, + "max-fee-usd", "", "", - "Max fee we're willing to consider. Only applies to zksync or zkwithdraw type payment.") + "Max fee (in USD) we're willing to consider.") cmd.Flags().StringVarP( &config.PaymasterAddress, "paymaster-address", "", @@ -82,16 +67,12 @@ func registerNodeAddress(cmd *cobra.Command, addr *string) { "/home/storj/.ethereum/geth.ipc", "Address of the ETH node to use") } + func CreatePayer(ctx context.Context, log *zap.Logger, config PayerConfig, nodeAddress string, chain string, spenderKeyPath string) (paymentPayer payer.Payer, err error) { spenderKey, spenderAddress, err := loadETHKey(spenderKeyPath, "spender") if err != nil { return nil, err } - var maxGas big.Int - _, ok := maxGas.SetString(config.MaxGas, 10) - if !ok { - return nil, errs.New("invalid max gas setting") - } owner := spenderAddress if config.Owner != "" { @@ -105,40 +86,17 @@ func CreatePayer(ctx context.Context, log *zap.Logger, config PayerConfig, nodeA if err != nil { return nil, err } - chainID, err := convertInt(chain, 0, "chain-id") + chainIDInt, err := convertInt(chain, 0, "chain-id") if err != nil { return nil, err } - - var maxFee *big.Int - if config.MaxFee != "" { - var tmp big.Int - if _, ok := tmp.SetString(config.MaxFee, 10); !ok { - return nil, errs.New("invalid max fee setting") - } - maxFee = &tmp - } - - var gasTipCap *big.Int - if config.GasTipCap != "" { - gasTipCap = new(big.Int) - _, ok = gasTipCap.SetString(config.GasTipCap, 10) - if !ok { - return nil, errs.New("invalid gas tip cap setting") - } - if gasTipCap.Cmp(big.NewInt(30*params.GWei)) > 0 { - return nil, errs.New("Gas tip cap is too high. Please use value less than 30 gwei") - } - - if gasTipCap.Cmp(big.NewInt(int64(100))) < 0 { - return nil, errs.New("Gas tip cap is negligible. Please check if you really used wei unit (or set 0)") - } - } + chainID := int(chainIDInt.Int64()) pt, err := payer.TypeFromString(config.PayerType) if err != nil { return nil, errs.Wrap(err) } + switch pt { case payer.Eth, payer.Polygon: var client *ethclient.Client @@ -154,12 +112,12 @@ func CreatePayer(ctx context.Context, log *zap.Logger, config PayerConfig, nodeA owner, spenderKey, chainID, - gasTipCap, - &maxGas, + eth.PayerOptions{}, ) if err != nil { return nil, errs.Wrap(err) } + case payer.ZkSyncEra: var paymasterAddress *common.Address var paymasterPayload []byte @@ -172,10 +130,9 @@ func CreatePayer(ctx context.Context, log *zap.Logger, config PayerConfig, nodeA common.HexToAddress(config.ContractAddress), nodeAddress, spenderKey, - int(chainID.Int64()), + chainID, paymasterAddress, - paymasterPayload, - maxFee) + paymasterPayload) if err != nil { return nil, errs.Wrap(err) } diff --git a/cmd/crybapy2/cmd_audit.go b/cmd/crybapy2/cmd_audit.go new file mode 100644 index 0000000..38f627c --- /dev/null +++ b/cmd/crybapy2/cmd_audit.go @@ -0,0 +1,202 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + + "github.com/zeebo/clingy" + "github.com/zeebo/errs" + "golang.org/x/exp/constraints" + "golang.org/x/exp/maps" + "storj.io/crypto-batch-payment/pkg/config" + "storj.io/crypto-batch-payment/pkg/fancy" + "storj.io/crypto-batch-payment/pkg/payer" + "storj.io/crypto-batch-payment/pkg/payouts2" + "storj.io/crypto-batch-payment/pkg/pipelinedb" + "storj.io/crypto-batch-payment/pkg/receipts" +) + +type cmdAudit struct { + config string + force bool + pretend bool + receiptsPath string +} + +func (cmd *cmdAudit) Setup(params clingy.Parameters) { + cmd.config = stringFlag(params, "config", "The configuration file", "./config.toml") + cmd.force = toggleFlag(params, "force", "Force writing the receipts even if there is a payouts problem", false) + cmd.pretend = toggleFlag(params, "pretend", "pretend to audit the payouts", false) + cmd.receiptsPath = stringArg(params, "RECEIPTSPATH", "Path on disk to write the receipts to") +} + +func (cmd *cmdAudit) Execute(ctx context.Context) error { + stdout := clingy.Stdout(ctx) + stderr := clingy.Stderr(ctx) + sink := &auditSink{out: stdout, err: stderr} + + cfg, err := config.Load(cmd.config) + if err != nil { + return fmt.Errorf("unable to load config: %w", err) + } + + auditors, err := cfg.NewAuditors(ctx) + if err != nil { + return fmt.Errorf("failed to init payers: %w", err) + } + defer auditors.Close() + + dbs, err := loadDBs(ctx) + if err != nil { + return err + } + + csvPaths, err := filepath.Glob("./*-prepayouts.csv") + if err != nil { + return fmt.Errorf("unable to locate prepayouts CSVs: %w", err) + } + + if len(csvPaths) == 0 { + return errors.New("no prepayout CSVs located in current directory") + } + + dbStats, err := payouts2.AuditDBs(ctx, dbs, csvPaths, sink) + if err != nil { + return err + } + + fancy.Finfoln(stdout, "DB audit complete.") + fancy.Fprintf(stdout, errorIfNonZero(dbStats.MissingCSVs), + "Missing CSVs................: %d\n", dbStats.MissingCSVs) + fancy.Fprintf(stdout, errorIfNonZero(dbStats.MissingDBs), + "Missing DBs.................: %d\n", dbStats.MissingDBs) + fancy.Fprintf(stdout, errorIfNonZero(dbStats.Mismatched), + "Mismatched Payouts..........: %d\n", dbStats.Mismatched) + + var receipts receipts.Buffer + var bad bool + var allTxStats = make(map[payer.Type]*payouts2.TransactionStats) + for payerType, db := range dbs { + auditor, ok := auditors[payerType] + if !ok { + fancy.Ferrorf(stdout, "No auditor for payer type %q\n", payerType) + bad = true + continue + } + if cmd.pretend { + auditor = pretendAuditor{} + } + fancy.Finfof(stdout, "Auditing %q transactions...\n", payerType) + txStats, err := payouts2.AuditTransactions(ctx, payerType, auditor, db, sink, &receipts) + if err != nil { + return err + } + fancy.Finfof(stdout, "Transactions audit complete (%s)\n", payerType) + allTxStats[payerType] = txStats + } + + sortedPayers := maps.Keys(allTxStats) + slices.Sort(sortedPayers) + + for _, payerType := range sortedPayers { + txStats := allTxStats[payerType] + fancy.Finfoln(stdout) + fancy.Finfof(stdout, "Transactions stats (%s):\n", payerType) + fancy.Finfof(stdout, "Total.......................: %d\n", txStats.Total) + if txStats.Confirmed != txStats.Total { + fancy.Fprintf(stdout, fancy.Warn, "Confirmed...................: %d\n", txStats.Confirmed) + } else { + fancy.Fprintf(stdout, fancy.Info, "Confirmed...................: %d\n", txStats.Confirmed) + } + fancy.Finfof(stdout, "False Confirmed.............: %d\n", txStats.FalseConfirmed) + fancy.Finfof(stdout, "Overpaid....................: %d\n", txStats.Overpaid) + if txStats.Skipped > 0 { + fancy.Fprintf(stdout, fancy.Warn, "Skipped.....................: %d\n", txStats.Skipped) + } else { + fancy.Finfof(stdout, "Skipped.....................: 0\n") + } + fancy.Finfof(stdout, "Unstarted...................: %d\n", txStats.Unstarted) + fancy.Finfof(stdout, "Pending.....................: %d\n", txStats.Pending) + fancy.Finfof(stdout, "Failed......................: %d\n", txStats.Failed) + fancy.Finfof(stdout, "Dropped.....................: %d\n", txStats.Dropped) + fancy.Finfof(stdout, "Unknown.....................: %d\n", txStats.Unknown) + + if txStats.MismatchedState > 0 { + fancy.Ferrorf(stdout, "Mismatched State............: %d\n", txStats.MismatchedState) + bad = true + } + + if txStats.DoublePays > 0 { + fancy.Ferrorf(stdout, "Double Pays.................: %d\n", txStats.DoublePays) + fancy.Ferrorf(stdout, "Double Pay Amount (raw STORJ value): %s\n", &txStats.DoublePayStorj) + bad = true + } + + if txStats.Confirmed+txStats.Skipped != txStats.Total { + bad = true + } + } + + if bad { + fancy.Finfoln(stdout) + fancy.Ferrorln(stdout, "There were one or more problems with the payouts") + } + + // If all payout groups are confirmed and a receipts output has been + // configured then dump the receipts CSV. + switch { + case cmd.receiptsPath == "": + case !bad || cmd.force: + fancy.Finfof(stdout, "Writing receipts to %s...\n", cmd.receiptsPath) + if err := os.WriteFile(cmd.receiptsPath, receipts.Finalize(), 0644); err != nil { + return errs.Wrap(err) + } + default: + fancy.Fwarnln(stdout, "Skipping writing receipts due to bad payouts (force writing with --force)") + } + + fancy.Finfoln(stdout, "Done.") + + return nil +} + +type auditSink struct { + out io.Writer + err io.Writer +} + +func (s *auditSink) ReportStatusf(format string, args ...any) { + fancy.Finfoln(s.out, fmt.Sprintf(format, args...)) +} + +func (s *auditSink) ReportWarnf(format string, args ...any) { + fancy.Fwarnln(s.err, fmt.Sprintf(format, args...)) +} + +func (s *auditSink) ReportErrorf(format string, args ...any) { + fancy.Ferrorln(s.err, fmt.Sprintf(format, args...)) +} + +func errorIfNonZero[T constraints.Integer](v T) fancy.Level { + if v != 0 { + return fancy.Error + } + return fancy.Info +} + +type pretendAuditor struct{} + +func (pretendAuditor) CheckTransactionState(ctx context.Context, hash string) (pipelinedb.TxState, error) { + return pipelinedb.TxConfirmed, nil +} + +func (pretendAuditor) CheckConfirmedTransactionState(ctx context.Context, hash string) (pipelinedb.TxState, error) { + return pipelinedb.TxConfirmed, nil +} + +func (pretendAuditor) Close() {} diff --git a/cmd/crybapy2/cmd_init.go b/cmd/crybapy2/cmd_init.go new file mode 100644 index 0000000..58e803c --- /dev/null +++ b/cmd/crybapy2/cmd_init.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "path/filepath" + "slices" + + "github.com/kyokomi/emoji/v2" + "github.com/shopspring/decimal" + "github.com/zeebo/clingy" + "golang.org/x/exp/maps" + + "storj.io/crypto-batch-payment/pkg/payouts2" +) + +type cmdInit struct { + bonusMultiplier decimal.Decimal + zksyncEraMultiplier decimal.Decimal +} + +func (c *cmdInit) Setup(params clingy.Parameters) { + c.bonusMultiplier = optDecimalFlag(params, "bonus-multiplier", "The bonus multiplier to apply to bonus payouts", "1") + c.zksyncEraMultiplier = optDecimalFlag(params, "zksync-era-multiplier", "The bonus multiplier to apply to zksync-era payouts", "1.03") +} + +func (c *cmdInit) Execute(ctx context.Context) error { + if !c.bonusMultiplier.IsPositive() { + return errors.New("bonus-multiplier must be positive") + } + if !c.zksyncEraMultiplier.IsPositive() { + return errors.New("zksync-era-multiplier must be positive") + } + + csvPaths, err := filepath.Glob("./*-prepayouts.csv") + if err != nil { + return fmt.Errorf("unable to locate prepayouts CSVs: %w", err) + } + + if len(csvPaths) == 0 { + return errors.New("no prepayout CSVs located in current directory") + } + + params := payouts2.InitParams{ + CSVPaths: csvPaths, + BonusMultiplier: c.bonusMultiplier, + ZksyncEraMultiplier: c.zksyncEraMultiplier, + } + ui := &initUI{stdout: clingy.Stdout(ctx)} + + return payouts2.Init(ctx, params, ui) +} + +type initCSVStats struct { + aggregated int + skipped [payouts2.RowSkipReasonMax]int +} + +type initUI struct { + stdout io.Writer + longestPath int + csvStats map[string]*initCSVStats +} + +func (i *initUI) Started(evt payouts2.StartedEvent) { + longestPath := 0 + for _, csvPath := range evt.CSVPaths { + if len(csvPath) > longestPath { + longestPath = len(csvPath) + } + } + i.longestPath = longestPath +} + +func (i *initUI) CSVLoaded(evt payouts2.CSVLoadedEvent) { + ji := ":white_check_mark:" + result := fmt.Sprint("OK") + if evt.Err != nil { + ji = ":x:" + result = evt.Err.Error() + } + format := fmt.Sprintf("%s %%%ds: %%s\n", ji, i.longestPath) + i.printf(format, evt.CSVPath, result) +} + +func (i *initUI) RowAggregated(evt payouts2.RowAggregatedEvent) { + stats := i.csvStatsFor(evt.CSVPath) + stats.aggregated++ +} + +func (i *initUI) RowSkipped(evt payouts2.RowSkippedEvent) { + stats := i.csvStatsFor(evt.CSVPath) + stats.skipped[evt.Reason]++ +} + +func (i *initUI) RowsAggregated(evt payouts2.RowsAggregatedEvent) { + stats := i.csvStatsFor(evt.CSVPath) + + format := fmt.Sprintf(":information_source: %%%ds ... %%d rows aggregated\n", i.longestPath) + i.printf(format, "", stats.aggregated) + + for rowSkipReason, count := range stats.skipped { + if count > 0 { + format := fmt.Sprintf(":warning: %%%ds ... %%d %%s rows skipped\n", i.longestPath) + i.printf(format, "", count, payouts2.RowSkipReason(rowSkipReason)) + } + } +} + +func (i *initUI) CSVsLoaded(evt payouts2.CSVsLoadedEvent) { + format := fmt.Sprintf(":information_source: %%%ds: %%d\n", i.longestPath) + + typeKeys := maps.Keys(evt.ByType) + slices.Sort(typeKeys) + + var total int + for _, payerType := range typeKeys { + count := len(evt.ByType[payerType]) + i.printf(format, payerType, count) + total += count + } + i.printf(format, "total", total) +} + +func (i *initUI) csvStatsFor(csvPath string) *initCSVStats { + stats, ok := i.csvStats[csvPath] + if ok { + return stats + } + + if i.csvStats == nil { + i.csvStats = make(map[string]*initCSVStats) + } + + stats = new(initCSVStats) + i.csvStats[csvPath] = stats + return stats +} + +func (i *initUI) printf(format string, args ...interface{}) { + _, _ = emoji.Fprintf(i.stdout, format, args...) +} diff --git a/cmd/crybapy2/cmd_run.go b/cmd/crybapy2/cmd_run.go new file mode 100644 index 0000000..e59f961 --- /dev/null +++ b/cmd/crybapy2/cmd_run.go @@ -0,0 +1,195 @@ +package main + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "math/big" + "slices" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/zeebo/clingy" + "go.uber.org/zap" + "golang.org/x/exp/maps" + + "storj.io/crypto-batch-payment/pkg/config" + "storj.io/crypto-batch-payment/pkg/payer" + "storj.io/crypto-batch-payment/pkg/payouts" + "storj.io/crypto-batch-payment/pkg/pipelinedb" +) + +type cmdRun struct { + config string + skipConfirmation bool + drain bool + pretend bool + retrySkipped bool +} + +func (cmd *cmdRun) Setup(params clingy.Parameters) { + cmd.config = stringFlag(params, "config", "The configuration file", "./config.toml") + cmd.skipConfirmation = toggleFlag(params, "skip-confirmation", "Run the payouts without asking for confirmation", false) + cmd.drain = toggleFlag(params, "drain", "drain existing transactions only", false) + cmd.pretend = toggleFlag(params, "pretend", "pretend to issue the payouts", false) + cmd.retrySkipped = toggleFlag(params, "retry-skipped", "retry skipped transactions", false) +} + +func (cmd *cmdRun) Execute(ctx context.Context) error { + cfg, err := config.Load(cmd.config) + if err != nil { + var mfe *config.MissingFieldsError + if errors.As(err, &mfe) { + return fmt.Errorf("unable to load config:\n%s", mfe.String()) + } + return fmt.Errorf("unable to load config: %w", err) + } + + quoter, err := cfg.CoinMarketCap.NewQuoter() + if err != nil { + return fmt.Errorf("unable to init coin market cap quoter: %w", err) + } + + payers, err := cfg.NewPayers(ctx) + if err != nil { + return fmt.Errorf("failed to init payers: %w", err) + } + defer payers.Close() + + dbs, err := loadDBs(ctx) + if err != nil { + return err + } + defer func() { _ = dbs.Close() }() + + type payoutRun struct { + db *pipelinedb.DB + payer payer.Payer + } + + var runs []payoutRun + dbOrder := maps.Keys(dbs) + slices.Sort(dbOrder) + for _, payerType := range dbOrder { + db := dbs[payerType] + + payer, ok := payers[payerType] + if !ok { + return fmt.Errorf("no payer configured for %q payouts database", payerType) + } + + if cmd.pretend { + payer = &pretendPayer{Payer: payer} + } + + runs = append(runs, payoutRun{db: db, payer: payer}) + } + + log, err := openLog(".") + if err != nil { + return fmt.Errorf("failed to open log: %w", err) + } + + promptConfirm := promptConfirm + if cmd.skipConfirmation { + promptConfirm = func(label string) error { + fmt.Printf("Skipping confirmation to %s!\n", label) + return nil + } + } + + payoutsCfg := payouts.Config{ + Quoter: quoter, + PipelineLimit: cfg.Pipeline.DepthLimit, + TxDelay: time.Duration(cfg.Pipeline.TxDelay), + RetrySkipped: cmd.retrySkipped, + Drain: cmd.drain, + PromptConfirm: promptConfirm, + ThresholdDivisor: cfg.Pipeline.ThresholdDivisor, + MaxFeeTolerationUSD: cfg.Pipeline.MaxFeeTolerationUSD, + } + + for _, run := range runs { + if err := payouts.Preview(ctx, payoutsCfg, run.db, run.payer); err != nil { + return err + } + fmt.Println() + } + + for _, run := range runs { + log := log.With(zap.Stringer("payer", run.payer)) + if err := payouts.Run(ctx, log, payoutsCfg, run.db, run.payer); err != nil { + return err + } + } + + return nil +} + +type pretendPayer struct { + config.Payer + nonce uint64 +} + +// NextNonce queries chain for the next available nonce value. +func (p *pretendPayer) NextNonce(ctx context.Context) (uint64, error) { + // if p.nonce == 0 { + // var err error + // p.nonce, err = p.Payer.NextNonce(ctx) + // return p.nonce, err + // } + p.nonce++ + return p.nonce, nil +} + +// GetETHBalance returns the ETH balance (in WEI). +func (pretendPayer) GetETHBalance(ctx context.Context) (*big.Int, error) { + balance, _ := new(big.Int).SetString("10_000_000_000_000_000_000", 0) + return balance, nil +} + +// GetTokenBalance returns with the available token balance in real value (with decimals). +func (pretendPayer) GetTokenBalance(ctx context.Context) (*big.Int, error) { + balance, _ := new(big.Int).SetString("1_000_000_000_000_000", 0) + return balance, nil +} + +// // CreateRawTransaction creates the chain transaction which will be persisted to the db. +func (pretendPayer) CreateRawTransaction(ctx context.Context, log *zap.Logger, params payer.TransactionParams) (payer.Transaction, common.Address, error) { + var hash common.Hash + var from common.Address + binary.BigEndian.PutUint64(from[:], params.Nonce) + binary.BigEndian.PutUint64(hash[:], params.Nonce) + + return payer.Transaction{ + Hash: hash.Hex(), + Nonce: params.Nonce, + EstimatedGasLimit: 50000, + EstimatedGasFeeCap: big.NewInt(1000000000), // 1gwei + }, from, nil +} + +func (pretendPayer) SendTransaction(ctx context.Context, log *zap.Logger, tx payer.Transaction) error { + return nil +} + +func (pretendPayer) CheckNonceGroup(ctx context.Context, log *zap.Logger, nonceGroup *pipelinedb.NonceGroup, checkOnly bool) (pipelinedb.TxState, []*pipelinedb.TxStatus, error) { + statuses := make([]*pipelinedb.TxStatus, 0, len(nonceGroup.Txs)) + for _, tx := range nonceGroup.Txs { + statuses = append(statuses, &pipelinedb.TxStatus{ + Hash: tx.Hash, + State: pipelinedb.TxConfirmed, + Receipt: &types.Receipt{ + TxHash: common.HexToHash(tx.Hash), + Logs: []*types.Log{}, + }, + }) + } + return pipelinedb.TxConfirmed, statuses, nil +} + +func (pretendPayer) PrintEstimate(ctx context.Context, remaining int64) error { + return nil +} diff --git a/cmd/crybapy2/helpers.go b/cmd/crybapy2/helpers.go new file mode 100644 index 0000000..412ea3c --- /dev/null +++ b/cmd/crybapy2/helpers.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "errors" + "fmt" + "path/filepath" + + "github.com/manifoldco/promptui" + "github.com/zeebo/errs" + "storj.io/crypto-batch-payment/pkg/payer" + "storj.io/crypto-batch-payment/pkg/pipelinedb" +) + +func promptConfirm(label string) error { + _, err := (&promptui.Prompt{ + Label: label, + IsConfirm: true, + }).Run() + if err != nil { + return errors.New("aborted") + } + return nil +} + +type dbMap map[payer.Type]*pipelinedb.DB + +func (m dbMap) Close() error { + var g errs.Group + for _, db := range m { + g.Add(db.Close()) + } + return g.Err() +} + +func loadDBs(ctx context.Context) (dbs dbMap, err error) { + defer func() { + if err != nil { + _ = dbs.Close() + } + }() + + dbPaths, err := filepath.Glob("./payout.*.db") + if err != nil { + return nil, fmt.Errorf("failed to determine payout database paths: %w", err) + } + if len(dbPaths) == 0 { + return nil, errors.New(`no payout databases found in the current directory; did you run "init"?`) + } + + dbs = make(dbMap) + for _, dbPath := range dbPaths { + var payerType payer.Type + switch dbPath { + case "payout.eth.db": + payerType = payer.Eth + case "payout.zksync-era.db": + payerType = payer.ZkSyncEra + default: + return nil, fmt.Errorf("no payer type configured for payout database %q", dbPath) + } + + db, err := pipelinedb.OpenDB(ctx, dbPath, false) + if err != nil { + return nil, fmt.Errorf("failed to open payout database: %w", err) + } + dbs[payerType] = db + } + + return dbs, nil +} diff --git a/cmd/crybapy2/log.go b/cmd/crybapy2/log.go new file mode 100644 index 0000000..cfef354 --- /dev/null +++ b/cmd/crybapy2/log.go @@ -0,0 +1,71 @@ +package main + +import ( + "os" + "path/filepath" + "time" + + "github.com/zeebo/errs" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func openLog(dataDir string) (*zap.Logger, error) { + // Ensure a logs directory exists + logsDir := filepath.Join(dataDir, "logs") + if err := os.MkdirAll(logsDir, 0755); err != nil { + return nil, errs.Wrap(err) + } + + // Name the log based on the current timestamp to millisecond precision + logName := time.Now().UTC().Format("2006.01.02.15.04.05.000Z") + ".json" + + // Convert to an absolute path for the file URI passed to zap + logsPath, err := filepath.Abs(filepath.Join(logsDir, logName)) + if err != nil { + return nil, errs.Wrap(err) + } + + stderrLog, err := openConsoleLog() + if err != nil { + return nil, err + } + + // Send debug to file as JSON + fileEncoder := zap.NewProductionEncoderConfig() + fileEncoder.EncodeTime = zapcore.ISO8601TimeEncoder + fileLog, err := (zap.Config{ + Level: zap.NewAtomicLevelAt(zap.DebugLevel), + Encoding: "json", + EncoderConfig: fileEncoder, + OutputPaths: []string{"file://" + logsPath}, + }).Build() + if err != nil { + return nil, errs.Wrap(err) + } + + log := zap.New(zapcore.NewTee(stderrLog.Core(), fileLog.Core())) + + // Overwrite the latest symlink + if err := os.Symlink(logName, filepath.Join(logsDir, ".latest")); err != nil { + return nil, errs.Wrap(err) + } + if err := os.Rename(filepath.Join(logsDir, ".latest"), filepath.Join(logsDir, "latest")); err != nil { + return nil, errs.Wrap(err) + } + + return log, nil +} + +// openConsoleLog creates a logger using info level + console. +func openConsoleLog() (*zap.Logger, error) { + stderrEncoder := zap.NewDevelopmentEncoderConfig() + stderrEncoder.EncodeLevel = zapcore.CapitalColorLevelEncoder + stderrLog, err := (zap.Config{ + Level: zap.NewAtomicLevelAt(zap.InfoLevel), + Encoding: "console", + EncoderConfig: stderrEncoder, + OutputPaths: []string{"stderr"}, + }).Build() + return stderrLog, errs.Wrap(err) +} diff --git a/cmd/crybapy2/main.go b/cmd/crybapy2/main.go new file mode 100644 index 0000000..8a6ace4 --- /dev/null +++ b/cmd/crybapy2/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + + "github.com/zeebo/clingy" +) + +func main() { + if err := run(); err != nil { + os.Exit(1) + } +} + +func run() error { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + ok, err := clingy.Environment{}.Run(ctx, func(cmds clingy.Commands) { + cmds.New("audit", "Audits payouts", new(cmdAudit)) + cmds.New("init", "Initializes payouts from prepayment CSVs", new(cmdInit)) + cmds.New("run", "Runs the payouts pipeline", new(cmdRun)) + }) + if err != nil { + fmt.Fprintf(os.Stderr, "failed: %+v\n", err) + return err + } + if !ok { + return errors.New("usage error") + } + return nil +} diff --git a/cmd/crybapy2/params.go b/cmd/crybapy2/params.go new file mode 100644 index 0000000..93b8d4d --- /dev/null +++ b/cmd/crybapy2/params.go @@ -0,0 +1,24 @@ +package main + +import ( + "strconv" + + "github.com/shopspring/decimal" + "github.com/zeebo/clingy" +) + +func stringFlag(params clingy.Parameters, name, desc, def string) string { + return params.Flag(name, desc, def).(string) +} + +func toggleFlag(params clingy.Parameters, name, desc string, def bool) bool { + return params.Flag(name, desc, def, clingy.Transform(strconv.ParseBool), clingy.Boolean).(bool) +} + +func optDecimalFlag(params clingy.Parameters, name, desc, def string) decimal.Decimal { + return params.Flag(name, desc, decimal.RequireFromString(def), clingy.Transform(decimal.NewFromString)).(decimal.Decimal) +} + +func stringArg(params clingy.Parameters, name, desc string) string { + return params.Arg(name, desc).(string) +} diff --git a/go.mod b/go.mod index 82bb5b2..27c8385 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23.1 require ( github.com/ethereum/go-ethereum v1.14.9 + github.com/kyokomi/emoji/v2 v2.2.13 github.com/logrusorgru/aurora v2.0.3+incompatible github.com/manifoldco/promptui v0.9.0 github.com/mattn/go-sqlite3 v1.14.23 @@ -12,10 +13,12 @@ require ( github.com/shopspring/decimal v1.4.0 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.9.0 + github.com/zeebo/clingy v0.0.0-20231031161054-57bed7a7d965 github.com/zeebo/errs v1.3.0 github.com/zeebo/errs/v2 v2.0.5 github.com/zksync-sdk/zksync2-go v0.7.0 go.uber.org/zap v1.27.0 + golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 storj.io/common v0.0.0-20211028030249-499e2fb72464 ) @@ -102,7 +105,6 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.27.0 // indirect - golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.25.0 // indirect golang.org/x/text v0.18.0 // indirect diff --git a/go.sum b/go.sum index e21438d..8985527 100644 --- a/go.sum +++ b/go.sum @@ -174,6 +174,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/kyokomi/emoji/v2 v2.2.12 h1:sSVA5nH9ebR3Zji1o31wu3yOwD1zKXQA2z0zUyeit60= +github.com/kyokomi/emoji/v2 v2.2.12/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE= +github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U= +github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE= github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= @@ -290,8 +294,11 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/clingy v0.0.0-20231031161054-57bed7a7d965 h1:ub+IewEE71wJ4YyJ6wn2GMM43EOhw7lV6L6LB0FoeGk= +github.com/zeebo/clingy v0.0.0-20231031161054-57bed7a7d965/go.mod h1:MHEhXvEfewflU7SSVKHI7nkdU+fpyxZ5XPPzj+5gYNw= github.com/zeebo/errs v1.3.0 h1:hmiaKqgYZzcVgRL1Vkc1Mn2914BbzB0IBxs+ebeutGs= github.com/zeebo/errs v1.3.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= +github.com/zeebo/errs/v2 v2.0.3/go.mod h1:OKmvVZt4UqpyJrYFykDKm168ZquJ55pbbIVUICNmLN0= github.com/zeebo/errs/v2 v2.0.5 h1:edtyFoQq9OBBsNPcu0b0wHtRvA8haukAHrGyOfWQGNI= github.com/zeebo/errs/v2 v2.0.5/go.mod h1:OKmvVZt4UqpyJrYFykDKm168ZquJ55pbbIVUICNmLN0= github.com/zksync-sdk/zksync2-go v0.7.0 h1:42LcNDYssBI4Yy4F8A+eLkrlIc3cBjYsdn2XeXy7Hbw= diff --git a/pkg/coinmarketcap/client.go b/pkg/coinmarketcap/client.go index b61658a..d38744e 100644 --- a/pkg/coinmarketcap/client.go +++ b/pkg/coinmarketcap/client.go @@ -28,6 +28,7 @@ const ( type Symbol string const ( + ETH = "ETH" STORJ = "STORJ" ) diff --git a/pkg/config/coinmarketcap.go b/pkg/config/coinmarketcap.go index 61c6ab5..229a886 100644 --- a/pkg/config/coinmarketcap.go +++ b/pkg/config/coinmarketcap.go @@ -9,8 +9,8 @@ import ( ) type CoinMarketCap struct { - APIURL string `toml:"api_url"` APIKeyPath Path `toml:"api_key_path"` + APIURL string `toml:"api_url"` CacheExpiry Duration `toml:"cache_expiry"` } diff --git a/pkg/config/config.go b/pkg/config/config.go index 7c63546..0fa0cb6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -3,17 +3,22 @@ package config import ( "bytes" "context" + "errors" "fmt" "os" "time" "github.com/pelletier/go-toml/v2" + "github.com/shopspring/decimal" "storj.io/crypto-batch-payment/pkg/coinmarketcap" + "storj.io/crypto-batch-payment/pkg/eth" "storj.io/crypto-batch-payment/pkg/payer" "storj.io/crypto-batch-payment/pkg/pipeline" ) +type MissingFieldsError = toml.StrictMissingError + type Config struct { Pipeline Pipeline `toml:"pipeline"` CoinMarketCap CoinMarketCap `toml:"coinmarketcap"` @@ -76,8 +81,21 @@ func (c *Config) NewAuditors(ctx context.Context) (_ Auditors, err error) { } type Pipeline struct { - DepthLimit int `toml:"depth_limit"` - TxDelay Duration `toml:"tx_delay"` + // DepthLimit is how many transactions to batch up at a time. + DepthLimit int `toml:"depth_limit"` + + // TxDelay is how long to sleep in between issuing transactions. + TxDelay Duration `toml:"tx_delay"` + + // ThresholdDivisor divides the payout amount to calculate the payout + // skip threshold per payout. If the estimated maximum payout transaction + // fees for the payout exceed this threshold then the payout is skipped. + ThresholdDivisor int `toml:"threshold_divisor"` + + // MaxFeeTolerationUSD is the maximum per-transfer fee to tolerate. Payouts + // that have an estimated fee higher than this value will be skipped. If + // <= 0, then no maximum fee is enforced. + MaxFeeTolerationUSD decimal.Decimal `toml:"max_fee_toleration_usd"` } func Load(path string) (Config, error) { @@ -90,22 +108,30 @@ func Load(path string) (Config, error) { func Parse(data []byte) (Config, error) { const ( - defaultPipelineDepthLimit = pipeline.DefaultLimit - defaultPipelineTxDelay = Duration(pipeline.DefaultTxDelay) + defaultPipelineDepthLimit = pipeline.DefaultLimit + defaultPipelineTxDelay = Duration(pipeline.DefaultTxDelay) + defaultThresholdDivisor = 4 + + defaultCoinMarketCapKeyPath = "~/.coinmarketcapkey" defaultCoinMarketCapAPIURL = coinmarketcap.ProductionAPIURL - defaultCoinMarketCapKeyPath = "~/.coinmarketcap" - defaultCoinMarketCapCacheExpiry = time.Second * 5 + defaultCoinMarketCapCacheExpiry = Duration(time.Second * 5) + ) + var ( + defaultGasFeeCapOverride = eth.RequireParseUnit("70gwei") + defaultMaxFeeTolerationUSD = decimal.Decimal{} ) config := Config{ Pipeline: Pipeline{ - DepthLimit: defaultPipelineDepthLimit, - TxDelay: defaultPipelineTxDelay, + DepthLimit: defaultPipelineDepthLimit, + TxDelay: defaultPipelineTxDelay, + ThresholdDivisor: defaultThresholdDivisor, + MaxFeeTolerationUSD: defaultMaxFeeTolerationUSD, }, CoinMarketCap: CoinMarketCap{ - APIURL: defaultCoinMarketCapAPIURL, APIKeyPath: ToPath(defaultCoinMarketCapKeyPath), - CacheExpiry: Duration(defaultCoinMarketCapCacheExpiry), + APIURL: defaultCoinMarketCapAPIURL, + CacheExpiry: defaultCoinMarketCapCacheExpiry, }, } @@ -115,5 +141,18 @@ func Parse(data []byte) (Config, error) { return Config{}, fmt.Errorf("failed to unmarshal config: %w", err) } + // Set ETH defaults. + if config.Eth != nil && config.Eth.GasFeeCapOverride == nil { + config.Eth.GasFeeCapOverride = &defaultGasFeeCapOverride + } + return config, nil } + +func DumpUnknownFields(err error) string { + var sme *toml.StrictMissingError + if errors.As(err, &sme) { + return sme.String() + } + return "" +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index dd0ad28..88725cc 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1,17 +1,18 @@ package config_test import ( - "math/big" "os/user" "path/filepath" "testing" "time" "github.com/ethereum/go-ethereum/common" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "storj.io/crypto-batch-payment/pkg/config" + "storj.io/crypto-batch-payment/pkg/eth" ) func TestLoad_Defaults(t *testing.T) { @@ -23,16 +24,18 @@ func TestLoad_Defaults(t *testing.T) { } cfg, err := config.Load("./testdata/defaults.toml") + t.Logf("unknown fields:\n%s", config.DumpUnknownFields(err)) require.NoError(t, err) assert.Equal(t, config.Config{ Pipeline: config.Pipeline{ - DepthLimit: 16, - TxDelay: 0, + DepthLimit: 16, + TxDelay: 0, + ThresholdDivisor: 4, }, CoinMarketCap: config.CoinMarketCap{ + APIKeyPath: homePath(".coinmarketcapkey"), APIURL: "https://pro-api.coinmarketcap.com", - APIKeyPath: homePath(".coinmarketcap"), CacheExpiry: 5000000000, }, Eth: &config.Eth{ @@ -41,15 +44,14 @@ func TestLoad_Defaults(t *testing.T) { ERC20ContractAddress: common.HexToAddress("0x1111111111111111111111111111111111111111"), ChainID: 0, Owner: nil, - MaxGas: nil, - GasTipCap: nil, + GasFeeCapOverride: ptrOf(eth.RequireParseUnit("70gwei")), + ExtraGasTip: nil, }, ZkSyncEra: &config.ZkSyncEra{ NodeAddress: "https://mainnet.era.zksync.io", SpenderKeyPath: homePath("some.key"), ERC20ContractAddress: common.HexToAddress("0x2222222222222222222222222222222222222222"), ChainID: 0, - MaxFee: nil, PaymasterAddress: nil, PaymasterPayload: nil, }, @@ -62,13 +64,15 @@ func TestLoad_Overrides(t *testing.T) { assert.Equal(t, config.Config{ Pipeline: config.Pipeline{ - DepthLimit: 24, - TxDelay: config.Duration(time.Minute), + DepthLimit: 24, + TxDelay: config.Duration(time.Minute), + ThresholdDivisor: 5, + MaxFeeTolerationUSD: decimal.RequireFromString("1.23"), }, CoinMarketCap: config.CoinMarketCap{ APIURL: "https://override.test", APIKeyPath: "override", - CacheExpiry: 5000000000, + CacheExpiry: 10000000000, }, Eth: &config.Eth{ NodeAddress: "https://override.test", @@ -76,15 +80,14 @@ func TestLoad_Overrides(t *testing.T) { ERC20ContractAddress: common.HexToAddress("0xe66652d41EE7e81d3fcAe1dF7F9B9f9411ac835e"), ChainID: 12345, Owner: ptrOf(common.HexToAddress("0xe66652d41EE7e81d3fcAe1dF7F9B9f9411ac835e")), - MaxGas: big.NewInt(80_000_000_000), - GasTipCap: big.NewInt(2_000_000_000), + GasFeeCapOverride: ptrOf(eth.RequireParseUnit("99gwei")), + ExtraGasTip: ptrOf(eth.RequireParseUnit("1gwei")), }, ZkSyncEra: &config.ZkSyncEra{ NodeAddress: "https://override.test", SpenderKeyPath: "override", ERC20ContractAddress: common.HexToAddress("0xe66652d41EE7e81d3fcAe1dF7F9B9f9411ac835e"), ChainID: 12345, - MaxFee: big.NewInt(5678), PaymasterAddress: ptrOf(common.HexToAddress("0xe66652d41EE7e81d3fcAe1dF7F9B9f9411ac835e")), PaymasterPayload: []byte("\x01\x23"), }, diff --git a/pkg/config/eth.go b/pkg/config/eth.go index 0c3aa72..dd5d1ec 100644 --- a/pkg/config/eth.go +++ b/pkg/config/eth.go @@ -2,8 +2,8 @@ package config import ( "context" + "crypto/ecdsa" "errors" - "math/big" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" @@ -14,8 +14,6 @@ import ( const ( defaultEthChainID = 1 - defaultMaxGas = "70_000_000_000" - defaultGasTipCap = "1_000_000_000" ) type Eth struct { @@ -24,11 +22,11 @@ type Eth struct { ERC20ContractAddress common.Address `toml:"erc20_contract_address"` ChainID int `toml:"chain_id"` Owner *common.Address `toml:"owner"` - MaxGas *big.Int `toml:"max_gas"` - GasTipCap *big.Int `toml:"gas_tip_cap"` + GasFeeCapOverride *eth.Unit `toml:"gas_fee_cap_override"` + ExtraGasTip *eth.Unit `toml:"extra_gas_tip"` } -func (c Eth) NewPayer(ctx context.Context) (_ Payer, err error) { +func (c *Eth) NewPayer(ctx context.Context) (_ Payer, err error) { // Check for required parameters if c.NodeAddress == "" { return nil, errors.New("node_address is not configured") @@ -41,14 +39,7 @@ func (c Eth) NewPayer(ctx context.Context) (_ Payer, err error) { if c.ChainID == 0 { c.ChainID = defaultEthChainID } - if c.MaxGas == nil { - c.MaxGas, _ = new(big.Int).SetString(defaultMaxGas, 0) - } - if c.GasTipCap == nil { - c.GasTipCap, _ = new(big.Int).SetString(defaultGasTipCap, 0) - } - - spenderKey, spenderAddress, err := loadSpenderKey(string(c.SpenderKeyPath)) + spenderKey, spenderAddress, err := c.NewSpender() if err != nil { return nil, err } @@ -58,9 +49,9 @@ func (c Eth) NewPayer(ctx context.Context) (_ Payer, err error) { owner = *c.Owner } - client, err := ethclient.Dial(c.NodeAddress) + client, err := c.NewClient() if err != nil { - return nil, errs.Wrap(err) + return nil, err } defer func() { if err != nil { @@ -68,14 +59,21 @@ func (c Eth) NewPayer(ctx context.Context) (_ Payer, err error) { } }() + var opts eth.PayerOptions + if c.GasFeeCapOverride != nil { + opts.GasFeeCapOverride = c.GasFeeCapOverride.WEIInt() + } + if c.ExtraGasTip != nil { + opts.ExtraGasTip = c.ExtraGasTip.WEIInt() + } + ethPayer, err := eth.NewPayer(ctx, client, c.ERC20ContractAddress, owner, spenderKey, - big.NewInt(int64(c.ChainID)), - c.GasTipCap, - c.MaxGas, + c.ChainID, + opts, ) if err != nil { return nil, errs.Wrap(err) @@ -87,7 +85,7 @@ func (c Eth) NewPayer(ctx context.Context) (_ Payer, err error) { }, nil } -func (c Eth) NewAuditor(ctx context.Context) (_ Auditor, err error) { +func (c *Eth) NewAuditor(ctx context.Context) (_ Auditor, err error) { // Check for required parameters if c.NodeAddress == "" { return nil, errors.New("node_address is not configured") @@ -99,3 +97,12 @@ func (c Eth) NewAuditor(ctx context.Context) (_ Auditor, err error) { } return ethAuditor, nil } + +func (c *Eth) NewClient() (*ethclient.Client, error) { + client, err := ethclient.Dial(c.NodeAddress) + return client, errs.Wrap(err) +} + +func (c *Eth) NewSpender() (*ecdsa.PrivateKey, common.Address, error) { + return loadSpenderKey(string(c.SpenderKeyPath)) +} diff --git a/pkg/config/testdata/defaults.toml b/pkg/config/testdata/defaults.toml index d600f1e..1ed76a3 100644 --- a/pkg/config/testdata/defaults.toml +++ b/pkg/config/testdata/defaults.toml @@ -1,10 +1,12 @@ [pipeline] # depth_limit = 16 # tx_delay = 0 +# threshold_divisor = 4 +# max_fee_toleration_usd = 0 [coinmarketcap] +# api_key_path = "~/.coinmarketcapkey" # api_url = "https://pro-api.coinmarketcap.com" -# api_key_path = "~/.coinmarketcap" # cache_expiry = "5s" [eth] @@ -12,14 +14,13 @@ node_address = "https://someaddress.test" spender_key_path = "~/some.key" erc20_contract_address = "0x1111111111111111111111111111111111111111" # owner = "" -# max_gas = "70_000_000_000" -# gas_tip_cap = "1_000_000_000" +# gas_fee_cap_override = "70gwei" +# extra_gas_tip = "0gwei" [zksync-era] node_address = "https://mainnet.era.zksync.io" spender_key_path = "~/some.key" erc20_contract_address = "0x2222222222222222222222222222222222222222" # chain_id = 324 -# max_fee = "" # paymaster_address = "" # paymaster_payload = "" diff --git a/pkg/config/testdata/override.toml b/pkg/config/testdata/override.toml index 775034f..f8ca813 100644 --- a/pkg/config/testdata/override.toml +++ b/pkg/config/testdata/override.toml @@ -1,11 +1,13 @@ [pipeline] depth_limit = 24 tx_delay = "1m" +threshold_divisor = 5 +max_fee_toleration_usd = 1.23 [coinmarketcap] -api_url = "https://override.test" api_key_path = "override" -cache_expiry = "5s" +api_url = "https://override.test" +cache_expiry = "10s" [eth] node_address = "https://override.test" @@ -13,14 +15,13 @@ spender_key_path = "override" erc20_contract_address = "0xe66652d41EE7e81d3fcAe1dF7F9B9f9411ac835e" chain_id = 12345 owner = "0xe66652d41EE7e81d3fcAe1dF7F9B9f9411ac835e" -max_gas = "80_000_000_000" -gas_tip_cap = "2_000_000_000" +gas_fee_cap_override = "99gwei" +extra_gas_tip = "1gwei" [zksync-era] node_address = "https://override.test" spender_key_path = "override" erc20_contract_address = "0xe66652d41EE7e81d3fcAe1dF7F9B9f9411ac835e" chain_id = 12345 -max_fee = "5678" paymaster_address = "0xe66652d41EE7e81d3fcAe1dF7F9B9f9411ac835e" paymaster_payload = "0123" diff --git a/pkg/config/zksync_era.go b/pkg/config/zksync_era.go index 7807642..36f8877 100644 --- a/pkg/config/zksync_era.go +++ b/pkg/config/zksync_era.go @@ -3,7 +3,6 @@ package config import ( "context" "errors" - "math/big" "github.com/ethereum/go-ethereum/common" @@ -19,7 +18,6 @@ type ZkSyncEra struct { SpenderKeyPath Path `toml:"spender_key_path"` ERC20ContractAddress common.Address `toml:"erc20_contract_address"` ChainID int `toml:"chain_id"` - MaxFee *big.Int `toml:"max_fee"` PaymasterAddress *common.Address `toml:"paymaster_address"` PaymasterPayload HexString `toml:"paymaster_payload"` } @@ -49,8 +47,7 @@ func (c ZkSyncEra) NewPayer(ctx context.Context) (_ Payer, err error) { spenderKey, c.ChainID, c.PaymasterAddress, - c.PaymasterPayload, - c.MaxFee) + c.PaymasterPayload) if err != nil { return nil, err } diff --git a/pkg/eth/payer.go b/pkg/eth/payer.go index 389d98a..d29c89e 100644 --- a/pkg/eth/payer.go +++ b/pkg/eth/payer.go @@ -3,6 +3,7 @@ package eth import ( "context" "crypto/ecdsa" + "errors" "fmt" "math/big" @@ -10,7 +11,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" - "github.com/shopspring/decimal" + "github.com/ethereum/go-ethereum/rpc" "go.uber.org/zap" batchpayment "storj.io/crypto-batch-payment/pkg" @@ -20,20 +21,8 @@ import ( "storj.io/crypto-batch-payment/pkg/contract" "storj.io/crypto-batch-payment/pkg/payer" "storj.io/crypto-batch-payment/pkg/pipelinedb" - "storj.io/crypto-batch-payment/pkg/storjtoken" ) -type Payer struct { - client Client - contract *contract.Token - owner common.Address - gasTipCap *big.Int - maxGas *big.Int - signer bind.SignerFn - from common.Address - tokenDecimals int32 -} - var ( _ payer.Payer = &Payer{} @@ -49,31 +38,42 @@ type Client interface { ethereum.TransactionReader } +type PayerOptions struct { + // GasFeeCap, if set, overrides the suggested gas fee cap for the + // transaction. The suggested gas fee cap will still be used for evaluating + // the skipped transaction threshold. + GasFeeCapOverride *big.Int + + // ExtraGasTip is an extra tip on top of the suggested gas tip cap. + ExtraGasTip *big.Int +} + +type Payer struct { + client Client + contract *contract.Token + owner common.Address + chainID int + signer bind.SignerFn + from common.Address + tokenDecimals int32 + opts PayerOptions +} + func NewPayer(ctx context.Context, client Client, contractAddress common.Address, owner common.Address, key *ecdsa.PrivateKey, - chainID *big.Int, - gasTipCap *big.Int, - maxGas *big.Int) (*Payer, error) { + chainID int, + opts PayerOptions, +) (*Payer, error) { - contract, err := contract.NewToken(contractAddress, &ignoreSend{ - ContractBackend: client, - }) + contract, err := contract.NewToken(contractAddress, client) if err != nil { return nil, errs.Wrap(err) } - if gasTipCap == nil { - suggestedGasTip, err := client.SuggestGasTipCap(ctx) - if err != nil { - return nil, errs.Wrap(err) - } - gasTipCap = suggestedGasTip - } - - opts, err := bind.NewKeyedTransactorWithChainID(key, chainID) + transactor, err := bind.NewKeyedTransactorWithChainID(key, big.NewInt(int64(chainID))) if err != nil { return nil, errs.Wrap(err) } @@ -85,13 +85,13 @@ func NewPayer(ctx context.Context, return &Payer{ owner: owner, - gasTipCap: gasTipCap, - maxGas: maxGas, + chainID: chainID, client: client, contract: contract, - signer: opts.Signer, - from: opts.From, + signer: transactor.Signer, + from: transactor.From, tokenDecimals: int32(decimals.Int64()), + opts: opts, }, nil } @@ -99,79 +99,113 @@ func (e *Payer) String() string { return payer.Eth.String() } -func (e *Payer) NextNonce(ctx context.Context) (uint64, error) { - return e.client.NonceAt(ctx, e.from, nil) +func (e *Payer) ChainID() int { + return e.chainID } -func (e *Payer) CheckPreconditions(ctx context.Context) (unmet []string, err error) { - lastBlock, err := e.client.BlockByNumber(ctx, nil) - if err != nil { - return nil, errs.Wrap(err) - } - - // max gas should be higher than the base fee + tip - if e.maxGas.Cmp(new(big.Int).Add(lastBlock.BaseFee(), e.gasTipCap)) < 0 { - unmet = append(unmet, fmt.Sprintf( - "the base fee of the last block (%s) plus the tip (%s) is larger than the max allowed gas price (%s)", - lastBlock.BaseFee(), e.gasTipCap, e.maxGas)) - } +func (e *Payer) Decimals() int32 { + return e.tokenDecimals +} - return unmet, nil +func (e *Payer) NextNonce(ctx context.Context) (uint64, error) { + return e.client.NonceAt(ctx, e.from, nil) +} +func (e *Payer) GetETHBalance(ctx context.Context) (*big.Int, error) { + balance, err := e.client.PendingBalanceAt(ctx, e.from) + return balance, errs.Wrap(err) } func (e *Payer) GetTokenBalance(ctx context.Context) (*big.Int, error) { - storjBalance, err := e.contract.BalanceOf(&bind.CallOpts{ + balance, err := e.contract.BalanceOf(&bind.CallOpts{ Pending: false, Context: ctx, }, e.owner) - return storjBalance, errs.Wrap(err) + return balance, errs.Wrap(err) } -func (e *Payer) CreateRawTransaction(ctx context.Context, log *zap.Logger, payouts []*pipelinedb.Payout, - nonce uint64, storjPrice decimal.Decimal) (_ payer.Transaction, _ common.Address, err error) { +func (e *Payer) GetGasInfo(ctx context.Context) (payer.GasInfo, error) { + gasLimit := uint64(contract.TokenTransferGasLimit) + if e.owner != e.from { + gasLimit = contract.TokenTransferFromGasLimit + } - var rawTx *types.Transaction - if len(payouts) > 1 { - return payer.Transaction{}, common.Address{}, errs.Errorf("multitransfer is not supported yet") + // SuggestGasPrice returns a gasFeeCap suggestion on EIP-1557 aware networks. + gasFeeCap, err := e.client.SuggestGasPrice(ctx) + if err != nil { + return payer.GasInfo{}, errs.Wrap(err) + } + + gasTipCap, err := e.client.SuggestGasTipCap(ctx) + if err != nil { + return payer.GasInfo{}, errs.Wrap(err) + } + + return payer.GasInfo{ + GasLimit: gasLimit, + GasFeeCap: gasFeeCap, + GasTipCap: gasTipCap, + }, nil +} + +func (e *Payer) CreateRawTransaction(ctx context.Context, log *zap.Logger, params payer.TransactionParams) (_ payer.Transaction, _ common.Address, err error) { + gasInfo, err := e.GetGasInfo(ctx) + if err != nil { + return payer.Transaction{}, common.Address{}, errs.Wrap(err) + } + + gasFeeCap := gasInfo.GasFeeCap + if e.opts.GasFeeCapOverride != nil { + gasFeeCap = e.opts.GasFeeCapOverride + } + + gasTipCap := gasInfo.GasTipCap + if e.opts.ExtraGasTip != nil { + gasTipCap = new(big.Int).Add(gasTipCap, e.opts.ExtraGasTip) } - payout := payouts[0] opts := &bind.TransactOpts{ From: e.from, Signer: e.signer, + GasLimit: gasInfo.GasLimit, + GasFeeCap: gasFeeCap, + GasTipCap: gasTipCap, Value: zero, - Nonce: new(big.Int).SetUint64(nonce), - GasTipCap: e.gasTipCap, - GasFeeCap: e.maxGas, + Nonce: new(big.Int).SetUint64(params.Nonce), Context: ctx, + NoSend: true, } - storjTokens := storjtoken.FromUSD(payout.USD, storjPrice, e.tokenDecimals) - var storjAllowance *big.Int + var rawTx *types.Transaction if e.owner == opts.From { - opts.GasLimit = contract.TokenTransferGasLimit - rawTx, err = e.contract.Transfer(opts, payout.Payee, storjTokens) + rawTx, err = e.contract.Transfer(opts, params.Payee, params.Tokens) + if err != nil { + return payer.Transaction{}, common.Address{}, errs.Wrap(err) + } } else { // Check the STORJ allowance to make sure there is enough. Since the // contract does not support pending operations, the best we can do // is check the live balance. - storjAllowance, err = e.contract.Allowance(&bind.CallOpts{ + storjAllowance, err := e.contract.Allowance(&bind.CallOpts{ Pending: false, Context: ctx, }, e.owner, opts.From) if err != nil { return payer.Transaction{}, common.Address{}, errs.Wrap(err) } - if storjAllowance.Cmp(storjTokens) < 0 { + if storjAllowance.Cmp(params.Tokens) < 0 { return payer.Transaction{}, common.Address{}, errs.Errorf("not enough STORJ allowance to cover transfer") } - opts.GasLimit = contract.TokenTransferFromGasLimit - rawTx, err = e.contract.TransferFrom(opts, e.owner, payout.Payee, storjTokens) - } - if err != nil { - return payer.Transaction{}, common.Address{}, errs.Wrap(err) + log = log.With( + zap.Stringer("spender", opts.From), + zap.Stringer("spender-storj-allowance", storjAllowance), + ) + + rawTx, err = e.contract.TransferFrom(opts, e.owner, params.Payee, params.Tokens) + if err != nil { + return payer.Transaction{}, common.Address{}, errs.Wrap(err) + } } // Grab the pending ETH balance for logging @@ -180,32 +214,37 @@ func (e *Payer) CreateRawTransaction(ctx context.Context, log *zap.Logger, payou return payer.Transaction{}, common.Address{}, errs.Wrap(err) } - fields := []zap.Field{ - zap.String("payee", payout.Payee.String()), - zap.String("usd", payout.USD.String()), - zap.String("pending-eth-balance", ethBalance.String()), - zap.String("hash", rawTx.Hash().String()), - } - - if e.owner != e.from { - fields = append(fields, - zap.String("spender", opts.From.String()), - zap.String("spender-storj-allowance", storjAllowance.String()), - ) - } - log.With(fields...).Info("Transaction is created") + log.Info("Transaction is created", + zap.Stringer("payee", params.Payee), + zap.Stringer("pending-eth-balance", ethBalance), + zap.Stringer("hash", rawTx.Hash()), + zap.Uint64("gas-limit", opts.GasLimit), + zap.Stringer("gas-fee-cap", opts.GasFeeCap), + ) return payer.Transaction{ - Hash: rawTx.Hash().Hex(), - Nonce: nonce, - Raw: rawTx, + Hash: rawTx.Hash().Hex(), + Nonce: params.Nonce, + EstimatedGasLimit: gasInfo.GasLimit, + EstimatedGasFeeCap: gasInfo.GasFeeCap, + Raw: rawTx, }, e.from, nil } func (e *Payer) SendTransaction(ctx context.Context, log *zap.Logger, t payer.Transaction) error { switch tx := t.Raw.(type) { case *types.Transaction: - return errs.Wrap(e.client.SendTransaction(ctx, tx)) + err := e.client.SendTransaction(ctx, tx) + if err != nil { + var rpcErr rpc.Error + if errors.As(err, &rpcErr) { + log.Error("Failed to send transaction", zap.String("msg", rpcErr.Error()), zap.Int("code", rpcErr.ErrorCode())) + } else { + log.Error("Failed to send transaction", zap.Error(err)) + } + return errs.Wrap(err) + } + return nil default: return errs.Errorf("payer doesn't support transaction %v", t.Raw) } @@ -377,7 +416,6 @@ func (e *Payer) getTransactionStatus(ctx context.Context, hashString string) (*p } func (e *Payer) PrintEstimate(ctx context.Context, remaining int64) error { - // TODO: revisit with multitransfer by estimating payout group size, etc. var gasPerTx *big.Int if e.owner == e.from { @@ -400,18 +438,3 @@ func (e *Payer) PrintEstimate(ctx context.Context, remaining int64) error { fmt.Printf("Remaining Gas Cost..........: %s\n", batchpayment.PrettyETH(estimatedGasCost)) return nil } - -func (e *Payer) GetTokenDecimals(ctx context.Context) (int32, error) { - return e.tokenDecimals, nil -} - -// ignoreSend wraps a contract backend to not actually send the transaction. -// It is used with the token contract to prepare and sign transactions but not -// actually send them. -type ignoreSend struct { - bind.ContractBackend -} - -func (p *ignoreSend) SendTransaction(ctx context.Context, tx *types.Transaction) error { - return nil -} diff --git a/pkg/eth/units.go b/pkg/eth/units.go new file mode 100644 index 0000000..b28b58a --- /dev/null +++ b/pkg/eth/units.go @@ -0,0 +1,162 @@ +package eth + +import ( + "fmt" + "math/big" + "regexp" + "strings" + + "github.com/shopspring/decimal" + "github.com/zeebo/errs" +) + +var ( + suffixRE = regexp.MustCompile(`([a-zA-Z]+)$`) +) + +// ParseUnit returns WEI amount from string. Units accepted are: +// - ETH +// - GWEI +// - WEI +// +// If no unit is specified, WEI is assumed. +func ParseUnit(s string) (Unit, error) { + if s == "" { + return Unit{}, errs.New("invalid unit: empty") + } + + var rawSuffix string + if m := suffixRE.FindStringSubmatch(s); m != nil { + rawSuffix = m[1] + } + + s = s[:len(s)-len(rawSuffix)] + + suffix := strings.ToUpper(rawSuffix) + if suffix == "" { + suffix = "WEI" + } + + var denom Denom + switch suffix { + case "ETH": + denom = ETH + case "GWEI": + denom = GWEI + case "WEI": + denom = WEI + default: + return Unit{}, errs.New("unsupported suffix %q", rawSuffix) + } + + raw, err := decimal.NewFromString(s) + if err != nil { + return Unit{}, errs.New("%s is not a valid %s unit: %v", s, suffix, err) + } + raw = raw.Shift(int32(denom)) + + wei := raw.Truncate(0) + if !wei.Equal(raw) { + return Unit{}, errs.New("%s is not a valid %s unit: must be a whole number of WEI but got %s", s, suffix, raw) + } + return Unit{wei: wei, denom: denom}, nil +} + +func RequireParseUnit(s string) Unit { + u, err := ParseUnit(s) + if err != nil { + panic(err) + } + return u +} + +type Unit struct { + wei decimal.Decimal + denom Denom +} + +func UnitFromInt(value int64, denom Denom) Unit { + return UnitFromDecimal(decimal.NewFromInt(value), denom) +} + +func UnitFromBigInt(value *big.Int, denom Denom) Unit { + return UnitFromDecimal(decimal.NewFromBigInt(value, 0), denom) +} + +func UnitFromDecimal(value decimal.Decimal, denom Denom) Unit { + if denom.String() == "" { + panic("denom is not one of WEI, GWEI, ETH") + } + wei := value.Shift(int32(denom)) + return Unit{wei: wei, denom: denom} +} + +func (u Unit) Mul(x Unit) Unit { + wei := u.wei.Mul(x.wei) + return Unit{wei: wei, denom: u.denom} +} + +func (u Unit) WEI() Unit { + return Unit{wei: u.wei, denom: WEI} +} + +func (u Unit) GWEI() Unit { + return Unit{wei: u.wei, denom: GWEI} +} + +func (u Unit) ETH() Unit { + return Unit{wei: u.wei, denom: ETH} +} + +func (u Unit) IsZero() bool { + return u.wei.IsZero() +} + +func (u Unit) String() string { + return fmt.Sprintf("%s%s", u.wei.Shift(-int32(u.denom)), u.denom) +} + +func (u Unit) Decimal(denom Denom) decimal.Decimal { + return u.wei.Shift(-int32(denom)) +} + +func (u Unit) WEIInt() *big.Int { + return u.wei.BigInt() +} + +func (u Unit) MarshalText() ([]byte, error) { + return []byte(u.String()), nil +} + +func (u *Unit) UnmarshalText(input []byte) error { + p, err := ParseUnit(string(input)) + if err != nil { + return err + } + *u = p + return nil +} + +func DecimalToWEIInt(value decimal.Decimal, denom Denom) *big.Int { + return UnitFromDecimal(value, denom).WEIInt() +} + +type Denom int32 + +const ( + WEI Denom = 0 + GWEI Denom = 9 + ETH Denom = 18 +) + +func (d Denom) String() string { + switch d { + case WEI: + return "wei" + case GWEI: + return "gwei" + case ETH: + return "eth" + } + return "" +} diff --git a/pkg/eth/units_test.go b/pkg/eth/units_test.go new file mode 100644 index 0000000..8103a05 --- /dev/null +++ b/pkg/eth/units_test.go @@ -0,0 +1,101 @@ +package eth_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "storj.io/crypto-batch-payment/pkg/eth" +) + +func TestParseUnits(t *testing.T) { + for _, tc := range []struct { + in string + out string + err string + }{ + { + in: "1.24ETH", + out: "1.24eth", + }, + { + in: "1.24eth", + out: "1.24eth", + }, + { + in: "1.24GWEI", + out: "1.24gwei", + }, + { + in: "1.24gwei", + out: "1.24gwei", + }, + { + in: "124WEI", + out: "124wei", + }, + { + in: "1.24e18", + out: "1240000000000000000wei", + }, + { + in: "", + err: "invalid unit: empty", + }, + { + in: "foo", + err: `unsupported suffix "foo"`, + }, + { + in: "1f1eth", + err: "1f1 is not a valid ETH unit: can't convert 1f1 to decimal", + }, + { + in: "1.24", + err: "1.24 is not a valid WEI unit: must be a whole number of WEI but got 1.24", + }, + } { + t.Run(tc.in, func(t *testing.T) { + out, err := eth.ParseUnit(tc.in) + if tc.err != "" { + assert.EqualError(t, err, tc.err) + assert.Zero(t, out) + return + } + require.NoError(t, err) + require.Equal(t, tc.out, out.String()) + }) + } +} + +func TestUnitsExchange(t *testing.T) { + var ( + weiUnit = eth.RequireParseUnit("1wei") + gweiUnit = eth.RequireParseUnit("1gwei") + ethUnit = eth.RequireParseUnit("1eth") + ) + + assert.Equal(t, "1wei", weiUnit.WEI().String()) + assert.Equal(t, "0.000000001gwei", weiUnit.GWEI().String()) + assert.Equal(t, "0.000000000000000001eth", weiUnit.ETH().String()) + assert.Equal(t, "1", weiUnit.WEIInt().String()) + assert.Equal(t, "1", weiUnit.Decimal(eth.WEI).String()) + assert.Equal(t, "0.000000001", weiUnit.Decimal(eth.GWEI).String()) + assert.Equal(t, "0.000000000000000001", weiUnit.Decimal(eth.ETH).String()) + + assert.Equal(t, "1000000000wei", gweiUnit.WEI().String()) + assert.Equal(t, "1gwei", gweiUnit.GWEI().String()) + assert.Equal(t, "0.000000001eth", gweiUnit.ETH().String()) + assert.Equal(t, "1000000000", gweiUnit.WEIInt().String()) + assert.Equal(t, "1", gweiUnit.Decimal(eth.GWEI).String()) + assert.Equal(t, "0.000000001", gweiUnit.Decimal(eth.ETH).String()) + + assert.Equal(t, "1000000000000000000wei", ethUnit.WEI().String()) + assert.Equal(t, "1000000000gwei", ethUnit.GWEI().String()) + assert.Equal(t, "1eth", ethUnit.ETH().String()) + assert.Equal(t, "1000000000000000000", ethUnit.WEIInt().String()) + assert.Equal(t, "1000000000000000000", ethUnit.WEIInt().String()) + assert.Equal(t, "1000000000", ethUnit.Decimal(eth.GWEI).String()) + assert.Equal(t, "1", ethUnit.Decimal(eth.ETH).String()) +} diff --git a/pkg/fancy/print.go b/pkg/fancy/print.go new file mode 100644 index 0000000..da0c6d6 --- /dev/null +++ b/pkg/fancy/print.go @@ -0,0 +1,80 @@ +package fancy + +import ( + "fmt" + "io" + + "github.com/logrusorgru/aurora" +) + +var ( + Info = aurora.White + Warn = aurora.Yellow + Error = aurora.Red +) + +type Level = func(arg any) aurora.Value + +func Println(level Level, args ...any) { + fmt.Println(level(fmt.Sprint(args...))) +} + +func Printf(level Level, format string, args ...any) { + fmt.Print(level(fmt.Sprintf(format, args...))) +} + +func Infoln(args ...any) { + Println(Info, args...) +} + +func Infof(format string, args ...any) { + Printf(Info, format, args...) +} + +func Warnln(args ...any) { + Println(Warn, args...) +} + +func Warnf(format string, args ...any) { + Printf(Warn, format, args...) +} + +func Errorln(args ...any) { + Println(Error, args...) +} + +func Errorf(format string, args ...any) { + Printf(Error, format, args...) +} + +func Fprintln(w io.Writer, level Level, args ...any) { + _, _ = fmt.Fprintln(w, level(fmt.Sprint(args...))) +} + +func Fprintf(w io.Writer, level Level, format string, args ...any) { + _, _ = fmt.Fprint(w, level(fmt.Sprintf(format, args...))) +} + +func Finfoln(w io.Writer, args ...any) { + Fprintln(w, Info, args...) +} + +func Finfof(w io.Writer, format string, args ...any) { + Fprintf(w, Info, format, args...) +} + +func Fwarnln(w io.Writer, args ...any) { + Fprintln(w, Warn, args...) +} + +func Fwarnf(w io.Writer, format string, args ...any) { + Fprintf(w, Warn, format, args...) +} + +func Ferrorln(w io.Writer, args ...any) { + Fprintln(w, Error, args...) +} + +func Ferrorf(w io.Writer, format string, args ...any) { + Fprintf(w, Error, format, args...) +} diff --git a/pkg/infura/client.go b/pkg/infura/client.go index 87740e9..c8d37fb 100644 --- a/pkg/infura/client.go +++ b/pkg/infura/client.go @@ -6,7 +6,7 @@ type Client interface { GetSuggestedGasFees(ctx context.Context, chainID int) (*SuggestedGasFees, error) } -func NewClient(apiKey string) *client { +func NewClient(apiKey string) Client { return &client{apiKey: apiKey} } diff --git a/pkg/infura/gas.go b/pkg/infura/gas.go index 09b4376..ea04706 100644 --- a/pkg/infura/gas.go +++ b/pkg/infura/gas.go @@ -58,8 +58,15 @@ func GetSuggestedGasFeesFromURL(ctx context.Context, reqURL string) (*SuggestedG return nil, errs.New("expected status code 200 but got %d: %s", resp.StatusCode, tryRead(resp.Body)) } + jsonBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errs.Wrap(err) + } + + fmt.Printf("SUGGESTED: %s\n", string(jsonBytes)) + out := new(SuggestedGasFees) - if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + if err := json.Unmarshal(jsonBytes, out); err != nil { return nil, errs.Wrap(err) } diff --git a/pkg/infura/gas_test.go b/pkg/infura/gas_test.go index 8c9decf..19595eb 100644 --- a/pkg/infura/gas_test.go +++ b/pkg/infura/gas_test.go @@ -30,7 +30,7 @@ func TestReal(t *testing.T) { fmt.Println(string(b)) } -func TestGetSuggestedGasFeesFromURL(t *testing.T) { +func TestGetSuggestedGasFees(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("ContentType", "application/json") _, _ = w.Write([]byte(` diff --git a/pkg/payer/audit.go b/pkg/payer/audit.go index 73699cf..6bac3ca 100644 --- a/pkg/payer/audit.go +++ b/pkg/payer/audit.go @@ -8,7 +8,6 @@ import ( // Auditor helps to validate transaction created by the appropriate payer. type Auditor interface { - // CheckTransactionState checks the transaction state of any transaction. CheckTransactionState(ctx context.Context, hash string) (pipelinedb.TxState, error) diff --git a/pkg/payer/payer.go b/pkg/payer/payer.go index 720e569..9ebbe7c 100644 --- a/pkg/payer/payer.go +++ b/pkg/payer/payer.go @@ -5,7 +5,6 @@ import ( "math/big" "github.com/ethereum/go-ethereum/common" - "github.com/shopspring/decimal" "go.uber.org/zap" "storj.io/crypto-batch-payment/pkg/pipelinedb" @@ -19,29 +18,66 @@ type Transaction struct { // Nonce is the nonce from the group. Nonce uint64 + // EstimatedGasLimit is an estimate of the amount of gas needed for the + // transaction. It is usually higher than the real amount used. + EstimatedGasLimit uint64 + + // EstimatedGasFeeCap is an estimate of the most per-gas fee that the + // transaction will incur. The actual fee will likely be smaller than this. + EstimatedGasFeeCap *big.Int + // Raw is the internal representation of transaction data. Raw any } +type TransactionParams struct { + // Nonce is the nonce to use for the transaction + Nonce uint64 + + // Payee is the recipient of the tokens + Payee common.Address + + // Tokens is the number of tokens to send + Tokens *big.Int +} + +type GasInfo struct { + // GasLimit is the gas limit which is typically higher than necessary but + // provides a meaningful cap on fees. + GasLimit uint64 + + // GasFeeCap is the EIP-1559 max fee, in WEI. + GasFeeCap *big.Int + + // GasTipCap is the EIP-1559 priority fee, in WEI. + GasTipCap *big.Int +} + // Payer is responsible for the final payment transfer. type Payer interface { // Strings returns a string describing the payer type. String() string + // ChainID is the ethereum chain ID this payer targets. + ChainID() int + + // Decimals returns with the decimal precision of the token. + Decimals() int32 + // NextNonce queries chain for the next available nonce value. NextNonce(ctx context.Context) (uint64, error) - // CheckPreconditions checks if transaction can be initiated. If unmet preconditions are returned then pipeline will try again after a short sleep. - CheckPreconditions(ctx context.Context) (unmet []string, err error) + // GetGasInfo returns estimated gas limits and caps. + GetGasInfo(context.Context) (GasInfo, error) + + // GetETHBalance returns the available ETH balance in WEI. + GetETHBalance(ctx context.Context) (*big.Int, error) // GetTokenBalance returns with the available token balance in real value (with decimals). GetTokenBalance(ctx context.Context) (*big.Int, error) - // GetTokenDecimals returns with the decimal precision of the token. - GetTokenDecimals(ctx context.Context) (int32, error) - // CreateRawTransaction creates the chain transaction which will be persisted to the db. - CreateRawTransaction(ctx context.Context, log *zap.Logger, payouts []*pipelinedb.Payout, nonce uint64, storjPrice decimal.Decimal) (tx Transaction, from common.Address, err error) + CreateRawTransaction(ctx context.Context, log *zap.Logger, params TransactionParams) (tx Transaction, from common.Address, err error) // SendTransaction submits the transaction created earlier. SendTransaction(ctx context.Context, log *zap.Logger, tx Transaction) error diff --git a/pkg/payer/sim.go b/pkg/payer/sim.go index 134cfdb..c842e1d 100644 --- a/pkg/payer/sim.go +++ b/pkg/payer/sim.go @@ -7,10 +7,10 @@ import ( "math/big" "github.com/ethereum/go-ethereum/common" - "github.com/shopspring/decimal" "github.com/zeebo/errs" "go.uber.org/zap" + "storj.io/crypto-batch-payment/pkg/contract" "storj.io/crypto-batch-payment/pkg/pipelinedb" ) @@ -43,23 +43,41 @@ func (s *SimPayer) String() string { return Sim.String() } +func (s *SimPayer) ChainID() int { + return 1337 +} + +func (s *SimPayer) Decimals() int32 { + return 8 +} + func (s *SimPayer) NextNonce(ctx context.Context) (uint64, error) { return uint64(0), nil } -func (s *SimPayer) CheckPreconditions(ctx context.Context) ([]string, error) { - return nil, nil +func (s *SimPayer) GetGasInfo(context.Context) (GasInfo, error) { + return GasInfo{ + GasLimit: contract.TokenTransferGasLimit, + GasFeeCap: big.NewInt(1_000_000_000), // 1 gwei + GasTipCap: big.NewInt(1_000_000), // .001 gwei + }, nil +} + +func (s *SimPayer) GetETHBalance(ctx context.Context) (*big.Int, error) { + balance, _ := new(big.Int).SetString("1000000000000000000", 0) + return balance, nil } func (s *SimPayer) GetTokenBalance(ctx context.Context) (*big.Int, error) { return big.NewInt(1000000000000), nil } -func (s *SimPayer) GetTokenDecimals(ctx context.Context) (int32, error) { - return 8, nil -} +func (s *SimPayer) CreateRawTransaction(ctx context.Context, log *zap.Logger, params TransactionParams) (tx Transaction, from common.Address, err error) { + gasInfo, err := s.GetGasInfo(ctx) + if err != nil { + return Transaction{}, common.Address{}, err + } -func (s *SimPayer) CreateRawTransaction(ctx context.Context, log *zap.Logger, payouts []*pipelinedb.Payout, nonce uint64, storjPrice decimal.Decimal) (tx Transaction, from common.Address, err error) { hash := make([]byte, 32) _, err = rand.Read(hash) if err != nil { @@ -67,8 +85,10 @@ func (s *SimPayer) CreateRawTransaction(ctx context.Context, log *zap.Logger, pa } txHash := common.BytesToHash(hash).String() return Transaction{ - Hash: txHash, - Nonce: nonce, + Hash: txHash, + Nonce: params.Nonce, + EstimatedGasLimit: gasInfo.GasLimit, + EstimatedGasFeeCap: gasInfo.GasFeeCap, Raw: map[string]interface{}{ "hash": txHash, }, @@ -102,13 +122,13 @@ func (s *SimPayer) CheckConfirmedTransactionState(ctx context.Context, hash stri return pipelinedb.TxConfirmed, nil } -type SimAuditor struct { -} +type SimAuditor struct{} // NewSimAuditor creates a simulated auditor. func NewSimAuditor() SimAuditor { return SimAuditor{} } + func (s SimAuditor) CheckTransactionState(ctx context.Context, hash string) (pipelinedb.TxState, error) { return pipelinedb.TxConfirmed, nil } diff --git a/pkg/payoutdb/payout_group.go b/pkg/payoutdb/payout_group.go index c0963b9..d31d80a 100644 --- a/pkg/payoutdb/payout_group.go +++ b/pkg/payoutdb/payout_group.go @@ -12,6 +12,8 @@ const ( unfinishedUnattachedConditional = ` WHERE final_tx_hash IS NULL + AND + ifnull(status, '') == '' AND id NOT IN (SELECT payout_group_id FROM tx WHERE state == 'pending') ` diff --git a/pkg/payoutdb/payoutdb.dbx b/pkg/payoutdb/payoutdb.dbx index 049174b..68cfa4c 100644 --- a/pkg/payoutdb/payoutdb.dbx +++ b/pkg/payoutdb/payoutdb.dbx @@ -18,6 +18,9 @@ model metadata ( // The owner address field owner text (nullable, updatable) + + // The bonus multiplier + field bonus_multiplier text (nullable, updatable) ) // payout represents a payout to a single address @@ -39,6 +42,9 @@ model payout ( // The payout group this payout is a part of field payout_group_id payout_group.id restrict + + // Whether or not the payout is mandatory + field mandatory bool ) // payout_group represents a group of one or more payouts @@ -46,9 +52,9 @@ model payout_group ( table payout_group key pk unique id - index ( - fields final_tx_hash - ) + index ( + fields final_tx_hash + ) field pk serial64 field created_at utimestamp (autoinsert) @@ -60,6 +66,9 @@ model payout_group ( // Hash of the transaction that completed this payout group. field final_tx_hash text (nullable, updatable) + + // Status of the payout group. One of ("", "skipped", "done") + field status text (nullable, updatable) ) // transaction represents a ETH transaction associated to a payout group. @@ -122,8 +131,8 @@ update transaction ( create metadata ( noreturn ) update metadata ( - where metadata.pk = ? - noreturn + where metadata.pk = ? + noreturn ) // load payouts in payout group @@ -151,7 +160,7 @@ read all ( read all ( select payout join payout.payout_group_id=payout_group.id - where payout_group.final_tx_hash = null + where payout_group.status = ? ) read count ( @@ -163,6 +172,11 @@ read count ( where payout_group.final_tx_hash = null ) +read count ( + select payout_group + where payout_group.status = ? +) + read all count ( select transaction ) @@ -189,10 +203,15 @@ read scalar ( ) // load metadata +read scalar ( + select metadata + where metadata.pk = ? +) + read first ( select metadata ) read first ( select metadata.version -) +) diff --git a/pkg/payoutdb/payoutdb.dbx.go b/pkg/payoutdb/payoutdb.dbx.go index 37a6eb7..4a1d1fb 100644 --- a/pkg/payoutdb/payoutdb.dbx.go +++ b/pkg/payoutdb/payoutdb.dbx.go @@ -273,6 +273,7 @@ func (obj *sqlite3DB) Schema() string { attempts INTEGER NOT NULL, spender TEXT, owner TEXT, + bonus_multiplier TEXT, PRIMARY KEY ( pk ) ); CREATE TABLE payout_group ( @@ -281,6 +282,7 @@ CREATE TABLE payout_group ( updated_at TIMESTAMP NOT NULL, id INTEGER NOT NULL, final_tx_hash TEXT, + status TEXT, PRIMARY KEY ( pk ), UNIQUE ( id ) ); @@ -291,6 +293,7 @@ CREATE TABLE payout ( payee TEXT NOT NULL, usd TEXT NOT NULL, payout_group_id INTEGER NOT NULL REFERENCES payout_group( id ), + mandatory INTEGER NOT NULL, PRIMARY KEY ( pk ) ); CREATE TABLE tx ( @@ -375,26 +378,29 @@ nextval: } type Metadata struct { - Pk int64 - CreatedAt time.Time - UpdatedAt time.Time - Version int - Attempts int - Spender *string - Owner *string + Pk int64 + CreatedAt time.Time + UpdatedAt time.Time + Version int + Attempts int + Spender *string + Owner *string + BonusMultiplier *string } func (Metadata) _Table() string { return "metadata" } type Metadata_Create_Fields struct { - Spender Metadata_Spender_Field - Owner Metadata_Owner_Field + Spender Metadata_Spender_Field + Owner Metadata_Owner_Field + BonusMultiplier Metadata_BonusMultiplier_Field } type Metadata_Update_Fields struct { - Attempts Metadata_Attempts_Field - Spender Metadata_Spender_Field - Owner Metadata_Owner_Field + Attempts Metadata_Attempts_Field + Spender Metadata_Spender_Field + Owner Metadata_Owner_Field + BonusMultiplier Metadata_BonusMultiplier_Field } type Metadata_Pk_Field struct { @@ -558,22 +564,57 @@ func (f Metadata_Owner_Field) value() interface{} { func (Metadata_Owner_Field) _Column() string { return "owner" } +type Metadata_BonusMultiplier_Field struct { + _set bool + _null bool + _value *string +} + +func Metadata_BonusMultiplier(v string) Metadata_BonusMultiplier_Field { + return Metadata_BonusMultiplier_Field{_set: true, _value: &v} +} + +func Metadata_BonusMultiplier_Raw(v *string) Metadata_BonusMultiplier_Field { + if v == nil { + return Metadata_BonusMultiplier_Null() + } + return Metadata_BonusMultiplier(*v) +} + +func Metadata_BonusMultiplier_Null() Metadata_BonusMultiplier_Field { + return Metadata_BonusMultiplier_Field{_set: true, _null: true} +} + +func (f Metadata_BonusMultiplier_Field) isnull() bool { return !f._set || f._null || f._value == nil } + +func (f Metadata_BonusMultiplier_Field) value() interface{} { + if !f._set || f._null { + return nil + } + return f._value +} + +func (Metadata_BonusMultiplier_Field) _Column() string { return "bonus_multiplier" } + type PayoutGroup struct { Pk int64 CreatedAt time.Time UpdatedAt time.Time Id int64 FinalTxHash *string + Status *string } func (PayoutGroup) _Table() string { return "payout_group" } type PayoutGroup_Create_Fields struct { FinalTxHash PayoutGroup_FinalTxHash_Field + Status PayoutGroup_Status_Field } type PayoutGroup_Update_Fields struct { FinalTxHash PayoutGroup_FinalTxHash_Field + Status PayoutGroup_Status_Field } type PayoutGroup_Pk_Field struct { @@ -686,6 +727,38 @@ func (f PayoutGroup_FinalTxHash_Field) value() interface{} { func (PayoutGroup_FinalTxHash_Field) _Column() string { return "final_tx_hash" } +type PayoutGroup_Status_Field struct { + _set bool + _null bool + _value *string +} + +func PayoutGroup_Status(v string) PayoutGroup_Status_Field { + return PayoutGroup_Status_Field{_set: true, _value: &v} +} + +func PayoutGroup_Status_Raw(v *string) PayoutGroup_Status_Field { + if v == nil { + return PayoutGroup_Status_Null() + } + return PayoutGroup_Status(*v) +} + +func PayoutGroup_Status_Null() PayoutGroup_Status_Field { + return PayoutGroup_Status_Field{_set: true, _null: true} +} + +func (f PayoutGroup_Status_Field) isnull() bool { return !f._set || f._null || f._value == nil } + +func (f PayoutGroup_Status_Field) value() interface{} { + if !f._set || f._null { + return nil + } + return f._value +} + +func (PayoutGroup_Status_Field) _Column() string { return "status" } + type Payout struct { Pk int64 CreatedAt time.Time @@ -693,6 +766,7 @@ type Payout struct { Payee string Usd string PayoutGroupId int64 + Mandatory bool } func (Payout) _Table() string { return "payout" } @@ -815,6 +889,25 @@ func (f Payout_PayoutGroupId_Field) value() interface{} { func (Payout_PayoutGroupId_Field) _Column() string { return "payout_group_id" } +type Payout_Mandatory_Field struct { + _set bool + _null bool + _value bool +} + +func Payout_Mandatory(v bool) Payout_Mandatory_Field { + return Payout_Mandatory_Field{_set: true, _value: v} +} + +func (f Payout_Mandatory_Field) value() interface{} { + if !f._set || f._null { + return nil + } + return f._value +} + +func (Payout_Mandatory_Field) _Column() string { return "mandatory" } + type Transaction struct { Pk int64 CreatedAt time.Time @@ -1552,7 +1645,8 @@ func (obj *sqlite3Impl) CreateNoReturn_Payout(ctx context.Context, payout_csv_line Payout_CsvLine_Field, payout_payee Payout_Payee_Field, payout_usd Payout_Usd_Field, - payout_payout_group_id Payout_PayoutGroupId_Field) ( + payout_payout_group_id Payout_PayoutGroupId_Field, + payout_mandatory Payout_Mandatory_Field) ( err error) { __now := obj.db.Hooks.Now().UTC() @@ -1561,11 +1655,12 @@ func (obj *sqlite3Impl) CreateNoReturn_Payout(ctx context.Context, __payee_val := payout_payee.value() __usd_val := payout_usd.value() __payout_group_id_val := payout_payout_group_id.value() + __mandatory_val := payout_mandatory.value() - var __embed_stmt = __sqlbundle_Literal("INSERT INTO payout ( created_at, csv_line, payee, usd, payout_group_id ) VALUES ( ?, ?, ?, ?, ? )") + var __embed_stmt = __sqlbundle_Literal("INSERT INTO payout ( created_at, csv_line, payee, usd, payout_group_id, mandatory ) VALUES ( ?, ?, ?, ?, ?, ? )") var __values []interface{} - __values = append(__values, __created_at_val, __csv_line_val, __payee_val, __usd_val, __payout_group_id_val) + __values = append(__values, __created_at_val, __csv_line_val, __payee_val, __usd_val, __payout_group_id_val, __mandatory_val) var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt) obj.logStmt(__stmt, __values...) @@ -1588,11 +1683,12 @@ func (obj *sqlite3Impl) CreateNoReturn_PayoutGroup(ctx context.Context, __updated_at_val := __now.UTC() __id_val := payout_group_id.value() __final_tx_hash_val := optional.FinalTxHash.value() + __status_val := optional.Status.value() - var __embed_stmt = __sqlbundle_Literal("INSERT INTO payout_group ( created_at, updated_at, id, final_tx_hash ) VALUES ( ?, ?, ?, ? )") + var __embed_stmt = __sqlbundle_Literal("INSERT INTO payout_group ( created_at, updated_at, id, final_tx_hash, status ) VALUES ( ?, ?, ?, ?, ? )") var __values []interface{} - __values = append(__values, __created_at_val, __updated_at_val, __id_val, __final_tx_hash_val) + __values = append(__values, __created_at_val, __updated_at_val, __id_val, __final_tx_hash_val, __status_val) var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt) obj.logStmt(__stmt, __values...) @@ -1667,11 +1763,12 @@ func (obj *sqlite3Impl) CreateNoReturn_Metadata(ctx context.Context, __attempts_val := metadata_attempts.value() __spender_val := optional.Spender.value() __owner_val := optional.Owner.value() + __bonus_multiplier_val := optional.BonusMultiplier.value() - var __embed_stmt = __sqlbundle_Literal("INSERT INTO metadata ( created_at, updated_at, version, attempts, spender, owner ) VALUES ( ?, ?, ?, ?, ?, ? )") + var __embed_stmt = __sqlbundle_Literal("INSERT INTO metadata ( created_at, updated_at, version, attempts, spender, owner, bonus_multiplier ) VALUES ( ?, ?, ?, ?, ?, ?, ? )") var __values []interface{} - __values = append(__values, __created_at_val, __updated_at_val, __version_val, __attempts_val, __spender_val, __owner_val) + __values = append(__values, __created_at_val, __updated_at_val, __version_val, __attempts_val, __spender_val, __owner_val, __bonus_multiplier_val) var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt) obj.logStmt(__stmt, __values...) @@ -1688,7 +1785,7 @@ func (obj *sqlite3Impl) All_Payout_By_PayoutGroupId(ctx context.Context, payout_payout_group_id Payout_PayoutGroupId_Field) ( rows []*Payout, err error) { - var __embed_stmt = __sqlbundle_Literal("SELECT payout.pk, payout.created_at, payout.csv_line, payout.payee, payout.usd, payout.payout_group_id FROM payout WHERE payout.payout_group_id = ?") + var __embed_stmt = __sqlbundle_Literal("SELECT payout.pk, payout.created_at, payout.csv_line, payout.payee, payout.usd, payout.payout_group_id, payout.mandatory FROM payout WHERE payout.payout_group_id = ?") var __values []interface{} __values = append(__values, payout_payout_group_id.value()) @@ -1704,7 +1801,7 @@ func (obj *sqlite3Impl) All_Payout_By_PayoutGroupId(ctx context.Context, for __rows.Next() { payout := &Payout{} - err = __rows.Scan(&payout.Pk, &payout.CreatedAt, &payout.CsvLine, &payout.Payee, &payout.Usd, &payout.PayoutGroupId) + err = __rows.Scan(&payout.Pk, &payout.CreatedAt, &payout.CsvLine, &payout.Payee, &payout.Usd, &payout.PayoutGroupId, &payout.Mandatory) if err != nil { return nil, obj.makeErr(err) } @@ -1775,7 +1872,7 @@ func (obj *sqlite3Impl) Get_PayoutGroup_By_Pk(ctx context.Context, payout_group_pk PayoutGroup_Pk_Field) ( payout_group *PayoutGroup, err error) { - var __embed_stmt = __sqlbundle_Literal("SELECT payout_group.pk, payout_group.created_at, payout_group.updated_at, payout_group.id, payout_group.final_tx_hash FROM payout_group WHERE payout_group.pk = ?") + var __embed_stmt = __sqlbundle_Literal("SELECT payout_group.pk, payout_group.created_at, payout_group.updated_at, payout_group.id, payout_group.final_tx_hash, payout_group.status FROM payout_group WHERE payout_group.pk = ?") var __values []interface{} __values = append(__values, payout_group_pk.value()) @@ -1784,7 +1881,7 @@ func (obj *sqlite3Impl) Get_PayoutGroup_By_Pk(ctx context.Context, obj.logStmt(__stmt, __values...) payout_group = &PayoutGroup{} - err = obj.driver.QueryRowContext(ctx, __stmt, __values...).Scan(&payout_group.Pk, &payout_group.CreatedAt, &payout_group.UpdatedAt, &payout_group.Id, &payout_group.FinalTxHash) + err = obj.driver.QueryRowContext(ctx, __stmt, __values...).Scan(&payout_group.Pk, &payout_group.CreatedAt, &payout_group.UpdatedAt, &payout_group.Id, &payout_group.FinalTxHash, &payout_group.Status) if err != nil { return (*PayoutGroup)(nil), obj.makeErr(err) } @@ -1795,7 +1892,7 @@ func (obj *sqlite3Impl) Get_PayoutGroup_By_Pk(ctx context.Context, func (obj *sqlite3Impl) All_Payout(ctx context.Context) ( rows []*Payout, err error) { - var __embed_stmt = __sqlbundle_Literal("SELECT payout.pk, payout.created_at, payout.csv_line, payout.payee, payout.usd, payout.payout_group_id FROM payout") + var __embed_stmt = __sqlbundle_Literal("SELECT payout.pk, payout.created_at, payout.csv_line, payout.payee, payout.usd, payout.payout_group_id, payout.mandatory FROM payout") var __values []interface{} @@ -1810,7 +1907,7 @@ func (obj *sqlite3Impl) All_Payout(ctx context.Context) ( for __rows.Next() { payout := &Payout{} - err = __rows.Scan(&payout.Pk, &payout.CreatedAt, &payout.CsvLine, &payout.Payee, &payout.Usd, &payout.PayoutGroupId) + err = __rows.Scan(&payout.Pk, &payout.CreatedAt, &payout.CsvLine, &payout.Payee, &payout.Usd, &payout.PayoutGroupId, &payout.Mandatory) if err != nil { return nil, obj.makeErr(err) } @@ -1823,12 +1920,19 @@ func (obj *sqlite3Impl) All_Payout(ctx context.Context) ( } -func (obj *sqlite3Impl) All_Payout_By_PayoutGroup_FinalTxHash_Is_Null(ctx context.Context) ( +func (obj *sqlite3Impl) All_Payout_By_PayoutGroup_Status(ctx context.Context, + payout_group_status PayoutGroup_Status_Field) ( rows []*Payout, err error) { - var __embed_stmt = __sqlbundle_Literal("SELECT payout.pk, payout.created_at, payout.csv_line, payout.payee, payout.usd, payout.payout_group_id FROM payout JOIN payout_group ON payout.payout_group_id = payout_group.id WHERE payout_group.final_tx_hash is NULL") + var __cond_0 = &__sqlbundle_Condition{Left: "payout_group.status", Equal: true, Right: "?", Null: true} + + var __embed_stmt = __sqlbundle_Literals{Join: "", SQLs: []__sqlbundle_SQL{__sqlbundle_Literal("SELECT payout.pk, payout.created_at, payout.csv_line, payout.payee, payout.usd, payout.payout_group_id, payout.mandatory FROM payout JOIN payout_group ON payout.payout_group_id = payout_group.id WHERE "), __cond_0}} var __values []interface{} + if !payout_group_status.isnull() { + __cond_0.Null = false + __values = append(__values, payout_group_status.value()) + } var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt) obj.logStmt(__stmt, __values...) @@ -1841,7 +1945,7 @@ func (obj *sqlite3Impl) All_Payout_By_PayoutGroup_FinalTxHash_Is_Null(ctx contex for __rows.Next() { payout := &Payout{} - err = __rows.Scan(&payout.Pk, &payout.CreatedAt, &payout.CsvLine, &payout.Payee, &payout.Usd, &payout.PayoutGroupId) + err = __rows.Scan(&payout.Pk, &payout.CreatedAt, &payout.CsvLine, &payout.Payee, &payout.Usd, &payout.PayoutGroupId, &payout.Mandatory) if err != nil { return nil, obj.makeErr(err) } @@ -1892,6 +1996,32 @@ func (obj *sqlite3Impl) Count_PayoutGroup_By_FinalTxHash_Is_Null(ctx context.Con } +func (obj *sqlite3Impl) Count_PayoutGroup_By_Status(ctx context.Context, + payout_group_status PayoutGroup_Status_Field) ( + count int64, err error) { + + var __cond_0 = &__sqlbundle_Condition{Left: "payout_group.status", Equal: true, Right: "?", Null: true} + + var __embed_stmt = __sqlbundle_Literals{Join: "", SQLs: []__sqlbundle_SQL{__sqlbundle_Literal("SELECT COUNT(*) FROM payout_group WHERE "), __cond_0}} + + var __values []interface{} + if !payout_group_status.isnull() { + __cond_0.Null = false + __values = append(__values, payout_group_status.value()) + } + + var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt) + obj.logStmt(__stmt, __values...) + + err = obj.driver.QueryRowContext(ctx, __stmt, __values...).Scan(&count) + if err != nil { + return 0, obj.makeErr(err) + } + + return count, nil + +} + func (obj *sqlite3Impl) All_Transaction(ctx context.Context) ( rows []*Transaction, err error) { @@ -2000,7 +2130,7 @@ func (obj *sqlite3Impl) Find_PayoutGroup_By_Id(ctx context.Context, payout_group_id PayoutGroup_Id_Field) ( payout_group *PayoutGroup, err error) { - var __embed_stmt = __sqlbundle_Literal("SELECT payout_group.pk, payout_group.created_at, payout_group.updated_at, payout_group.id, payout_group.final_tx_hash FROM payout_group WHERE payout_group.id = ?") + var __embed_stmt = __sqlbundle_Literal("SELECT payout_group.pk, payout_group.created_at, payout_group.updated_at, payout_group.id, payout_group.final_tx_hash, payout_group.status FROM payout_group WHERE payout_group.id = ?") var __values []interface{} __values = append(__values, payout_group_id.value()) @@ -2009,7 +2139,7 @@ func (obj *sqlite3Impl) Find_PayoutGroup_By_Id(ctx context.Context, obj.logStmt(__stmt, __values...) payout_group = &PayoutGroup{} - err = obj.driver.QueryRowContext(ctx, __stmt, __values...).Scan(&payout_group.Pk, &payout_group.CreatedAt, &payout_group.UpdatedAt, &payout_group.Id, &payout_group.FinalTxHash) + err = obj.driver.QueryRowContext(ctx, __stmt, __values...).Scan(&payout_group.Pk, &payout_group.CreatedAt, &payout_group.UpdatedAt, &payout_group.Id, &payout_group.FinalTxHash, &payout_group.Status) if err == sql.ErrNoRows { return (*PayoutGroup)(nil), nil } @@ -2044,10 +2174,34 @@ func (obj *sqlite3Impl) Find_Transaction_By_Hash(ctx context.Context, } +func (obj *sqlite3Impl) Find_Metadata_By_Pk(ctx context.Context, + metadata_pk Metadata_Pk_Field) ( + metadata *Metadata, err error) { + + var __embed_stmt = __sqlbundle_Literal("SELECT metadata.pk, metadata.created_at, metadata.updated_at, metadata.version, metadata.attempts, metadata.spender, metadata.owner, metadata.bonus_multiplier FROM metadata WHERE metadata.pk = ?") + + var __values []interface{} + __values = append(__values, metadata_pk.value()) + + var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt) + obj.logStmt(__stmt, __values...) + + metadata = &Metadata{} + err = obj.driver.QueryRowContext(ctx, __stmt, __values...).Scan(&metadata.Pk, &metadata.CreatedAt, &metadata.UpdatedAt, &metadata.Version, &metadata.Attempts, &metadata.Spender, &metadata.Owner, &metadata.BonusMultiplier) + if err == sql.ErrNoRows { + return (*Metadata)(nil), nil + } + if err != nil { + return (*Metadata)(nil), obj.makeErr(err) + } + return metadata, nil + +} + func (obj *sqlite3Impl) First_Metadata(ctx context.Context) ( metadata *Metadata, err error) { - var __embed_stmt = __sqlbundle_Literal("SELECT metadata.pk, metadata.created_at, metadata.updated_at, metadata.version, metadata.attempts, metadata.spender, metadata.owner FROM metadata LIMIT 1 OFFSET 0") + var __embed_stmt = __sqlbundle_Literal("SELECT metadata.pk, metadata.created_at, metadata.updated_at, metadata.version, metadata.attempts, metadata.spender, metadata.owner, metadata.bonus_multiplier FROM metadata LIMIT 1 OFFSET 0") var __values []interface{} @@ -2068,7 +2222,7 @@ func (obj *sqlite3Impl) First_Metadata(ctx context.Context) ( } metadata = &Metadata{} - err = __rows.Scan(&metadata.Pk, &metadata.CreatedAt, &metadata.UpdatedAt, &metadata.Version, &metadata.Attempts, &metadata.Spender, &metadata.Owner) + err = __rows.Scan(&metadata.Pk, &metadata.CreatedAt, &metadata.UpdatedAt, &metadata.Version, &metadata.Attempts, &metadata.Spender, &metadata.Owner, &metadata.BonusMultiplier) if err != nil { return nil, obj.makeErr(err) } @@ -2127,6 +2281,11 @@ func (obj *sqlite3Impl) UpdateNoReturn_PayoutGroup_By_Id(ctx context.Context, __sets_sql.SQLs = append(__sets_sql.SQLs, __sqlbundle_Literal("final_tx_hash = ?")) } + if update.Status._set { + __values = append(__values, update.Status.value()) + __sets_sql.SQLs = append(__sets_sql.SQLs, __sqlbundle_Literal("status = ?")) + } + __now := obj.db.Hooks.Now().UTC() __values = append(__values, __now.UTC()) @@ -2216,6 +2375,11 @@ func (obj *sqlite3Impl) UpdateNoReturn_Metadata_By_Pk(ctx context.Context, __sets_sql.SQLs = append(__sets_sql.SQLs, __sqlbundle_Literal("owner = ?")) } + if update.BonusMultiplier._set { + __values = append(__values, update.BonusMultiplier.value()) + __sets_sql.SQLs = append(__sets_sql.SQLs, __sqlbundle_Literal("bonus_multiplier = ?")) + } + __now := obj.db.Hooks.Now().UTC() __values = append(__values, __now.UTC()) @@ -2240,13 +2404,13 @@ func (obj *sqlite3Impl) getLastPayout(ctx context.Context, pk int64) ( payout *Payout, err error) { - var __embed_stmt = __sqlbundle_Literal("SELECT payout.pk, payout.created_at, payout.csv_line, payout.payee, payout.usd, payout.payout_group_id FROM payout WHERE _rowid_ = ?") + var __embed_stmt = __sqlbundle_Literal("SELECT payout.pk, payout.created_at, payout.csv_line, payout.payee, payout.usd, payout.payout_group_id, payout.mandatory FROM payout WHERE _rowid_ = ?") var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt) obj.logStmt(__stmt, pk) payout = &Payout{} - err = obj.driver.QueryRowContext(ctx, __stmt, pk).Scan(&payout.Pk, &payout.CreatedAt, &payout.CsvLine, &payout.Payee, &payout.Usd, &payout.PayoutGroupId) + err = obj.driver.QueryRowContext(ctx, __stmt, pk).Scan(&payout.Pk, &payout.CreatedAt, &payout.CsvLine, &payout.Payee, &payout.Usd, &payout.PayoutGroupId, &payout.Mandatory) if err != nil { return (*Payout)(nil), obj.makeErr(err) } @@ -2258,13 +2422,13 @@ func (obj *sqlite3Impl) getLastPayoutGroup(ctx context.Context, pk int64) ( payout_group *PayoutGroup, err error) { - var __embed_stmt = __sqlbundle_Literal("SELECT payout_group.pk, payout_group.created_at, payout_group.updated_at, payout_group.id, payout_group.final_tx_hash FROM payout_group WHERE _rowid_ = ?") + var __embed_stmt = __sqlbundle_Literal("SELECT payout_group.pk, payout_group.created_at, payout_group.updated_at, payout_group.id, payout_group.final_tx_hash, payout_group.status FROM payout_group WHERE _rowid_ = ?") var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt) obj.logStmt(__stmt, pk) payout_group = &PayoutGroup{} - err = obj.driver.QueryRowContext(ctx, __stmt, pk).Scan(&payout_group.Pk, &payout_group.CreatedAt, &payout_group.UpdatedAt, &payout_group.Id, &payout_group.FinalTxHash) + err = obj.driver.QueryRowContext(ctx, __stmt, pk).Scan(&payout_group.Pk, &payout_group.CreatedAt, &payout_group.UpdatedAt, &payout_group.Id, &payout_group.FinalTxHash, &payout_group.Status) if err != nil { return (*PayoutGroup)(nil), obj.makeErr(err) } @@ -2294,13 +2458,13 @@ func (obj *sqlite3Impl) getLastMetadata(ctx context.Context, pk int64) ( metadata *Metadata, err error) { - var __embed_stmt = __sqlbundle_Literal("SELECT metadata.pk, metadata.created_at, metadata.updated_at, metadata.version, metadata.attempts, metadata.spender, metadata.owner FROM metadata WHERE _rowid_ = ?") + var __embed_stmt = __sqlbundle_Literal("SELECT metadata.pk, metadata.created_at, metadata.updated_at, metadata.version, metadata.attempts, metadata.spender, metadata.owner, metadata.bonus_multiplier FROM metadata WHERE _rowid_ = ?") var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt) obj.logStmt(__stmt, pk) metadata = &Metadata{} - err = obj.driver.QueryRowContext(ctx, __stmt, pk).Scan(&metadata.Pk, &metadata.CreatedAt, &metadata.UpdatedAt, &metadata.Version, &metadata.Attempts, &metadata.Spender, &metadata.Owner) + err = obj.driver.QueryRowContext(ctx, __stmt, pk).Scan(&metadata.Pk, &metadata.CreatedAt, &metadata.UpdatedAt, &metadata.Version, &metadata.Attempts, &metadata.Spender, &metadata.Owner, &metadata.BonusMultiplier) if err != nil { return (*Metadata)(nil), obj.makeErr(err) } @@ -2432,13 +2596,14 @@ func (rx *Rx) All_Payout_By_PayoutGroupId(ctx context.Context, return tx.All_Payout_By_PayoutGroupId(ctx, payout_payout_group_id) } -func (rx *Rx) All_Payout_By_PayoutGroup_FinalTxHash_Is_Null(ctx context.Context) ( +func (rx *Rx) All_Payout_By_PayoutGroup_Status(ctx context.Context, + payout_group_status PayoutGroup_Status_Field) ( rows []*Payout, err error) { var tx *Tx if tx, err = rx.getTx(ctx); err != nil { return } - return tx.All_Payout_By_PayoutGroup_FinalTxHash_Is_Null(ctx) + return tx.All_Payout_By_PayoutGroup_Status(ctx, payout_group_status) } func (rx *Rx) All_Transaction(ctx context.Context) ( @@ -2488,6 +2653,16 @@ func (rx *Rx) Count_PayoutGroup_By_FinalTxHash_Is_Null(ctx context.Context) ( return tx.Count_PayoutGroup_By_FinalTxHash_Is_Null(ctx) } +func (rx *Rx) Count_PayoutGroup_By_Status(ctx context.Context, + payout_group_status PayoutGroup_Status_Field) ( + count int64, err error) { + var tx *Tx + if tx, err = rx.getTx(ctx); err != nil { + return + } + return tx.Count_PayoutGroup_By_Status(ctx, payout_group_status) +} + func (rx *Rx) Count_Payout_By_PayoutGroupId(ctx context.Context, payout_payout_group_id Payout_PayoutGroupId_Field) ( count int64, err error) { @@ -2534,13 +2709,14 @@ func (rx *Rx) CreateNoReturn_Payout(ctx context.Context, payout_csv_line Payout_CsvLine_Field, payout_payee Payout_Payee_Field, payout_usd Payout_Usd_Field, - payout_payout_group_id Payout_PayoutGroupId_Field) ( + payout_payout_group_id Payout_PayoutGroupId_Field, + payout_mandatory Payout_Mandatory_Field) ( err error) { var tx *Tx if tx, err = rx.getTx(ctx); err != nil { return } - return tx.CreateNoReturn_Payout(ctx, payout_csv_line, payout_payee, payout_usd, payout_payout_group_id) + return tx.CreateNoReturn_Payout(ctx, payout_csv_line, payout_payee, payout_usd, payout_payout_group_id, payout_mandatory) } @@ -2577,6 +2753,16 @@ func (rx *Rx) Create_Transaction(ctx context.Context, } +func (rx *Rx) Find_Metadata_By_Pk(ctx context.Context, + metadata_pk Metadata_Pk_Field) ( + metadata *Metadata, err error) { + var tx *Tx + if tx, err = rx.getTx(ctx); err != nil { + return + } + return tx.Find_Metadata_By_Pk(ctx, metadata_pk) +} + func (rx *Rx) Find_PayoutGroup_By_Id(ctx context.Context, payout_group_id PayoutGroup_Id_Field) ( payout_group *PayoutGroup, err error) { @@ -2666,7 +2852,8 @@ type Methods interface { payout_payout_group_id Payout_PayoutGroupId_Field) ( rows []*Payout, err error) - All_Payout_By_PayoutGroup_FinalTxHash_Is_Null(ctx context.Context) ( + All_Payout_By_PayoutGroup_Status(ctx context.Context, + payout_group_status PayoutGroup_Status_Field) ( rows []*Payout, err error) All_Transaction(ctx context.Context) ( @@ -2686,6 +2873,10 @@ type Methods interface { Count_PayoutGroup_By_FinalTxHash_Is_Null(ctx context.Context) ( count int64, err error) + Count_PayoutGroup_By_Status(ctx context.Context, + payout_group_status PayoutGroup_Status_Field) ( + count int64, err error) + Count_Payout_By_PayoutGroupId(ctx context.Context, payout_payout_group_id Payout_PayoutGroupId_Field) ( count int64, err error) @@ -2707,7 +2898,8 @@ type Methods interface { payout_csv_line Payout_CsvLine_Field, payout_payee Payout_Payee_Field, payout_usd Payout_Usd_Field, - payout_payout_group_id Payout_PayoutGroupId_Field) ( + payout_payout_group_id Payout_PayoutGroupId_Field, + payout_mandatory Payout_Mandatory_Field) ( err error) CreateNoReturn_PayoutGroup(ctx context.Context, @@ -2729,6 +2921,10 @@ type Methods interface { optional Transaction_Create_Fields) ( transaction *Transaction, err error) + Find_Metadata_By_Pk(ctx context.Context, + metadata_pk Metadata_Pk_Field) ( + metadata *Metadata, err error) + Find_PayoutGroup_By_Id(ctx context.Context, payout_group_id PayoutGroup_Id_Field) ( payout_group *PayoutGroup, err error) diff --git a/pkg/payouts/audit.go b/pkg/payouts/audit.go index b75207a..ab1b3d7 100644 --- a/pkg/payouts/audit.go +++ b/pkg/payouts/audit.go @@ -35,7 +35,7 @@ type AuditStats struct { Unknown int64 Mismatched int64 DoublePays int64 - DoublePayStorj *big.Int + DoublePayStorj big.Int } func Audit(ctx context.Context, dir string, csvPath string, payerType payer.Type, nodeAddress string, chainID int, sink AuditSink, receiptsOut string, receiptsForce bool) (*AuditStats, error) { @@ -90,8 +90,7 @@ func Audit(ctx context.Context, dir string, csvPath string, payerType payer.Type } stats := &AuditStats{ - Total: int64(len(dbPayouts)), - DoublePayStorj: new(big.Int), + Total: int64(len(dbPayouts)), } csvPayoutsByLine := make(map[int]*pipelinedb.Payout) @@ -185,7 +184,7 @@ func Audit(ctx context.Context, dir string, csvPath string, payerType payer.Type if tx.State == pipelinedb.TxDropped && state == pipelinedb.TxConfirmed { sink.ReportErrorf("Double pay for payout group %d (tokens=%s)", tx.PayoutGroupID, tx.StorjTokens) stats.DoublePays++ - stats.DoublePayStorj.Add(stats.DoublePayStorj, tx.StorjTokens) + stats.DoublePayStorj.Add(&stats.DoublePayStorj, tx.StorjTokens) } else { sink.ReportWarnf("TX state mismatch on hash %q (db=%q, node=%q)", tx.Hash, tx.State, state) } diff --git a/pkg/payouts/run.go b/pkg/payouts/run.go index 8bf8fdd..2ad0574 100644 --- a/pkg/payouts/run.go +++ b/pkg/payouts/run.go @@ -5,13 +5,14 @@ import ( "fmt" "time" + "github.com/shopspring/decimal" "go.uber.org/zap" - "storj.io/crypto-batch-payment/pkg/payer" - "storj.io/crypto-batch-payment/pkg/pipelinedb" - "storj.io/crypto-batch-payment/pkg/coinmarketcap" + "storj.io/crypto-batch-payment/pkg/fancy" + "storj.io/crypto-batch-payment/pkg/payer" "storj.io/crypto-batch-payment/pkg/pipeline" + "storj.io/crypto-batch-payment/pkg/pipelinedb" "storj.io/crypto-batch-payment/pkg/storjtoken" ) @@ -22,13 +23,26 @@ type Config struct { TxDelay time.Duration + RetrySkipped bool + Drain bool + // ThresholdDivisor divides a payout amount to determine the fee threshold. + // If a payout fee is larger than the fee threshold then the payout is + // skipped. If ThresholdDivisor <= 0, no payouts will be skipped. + ThresholdDivisor int + + // MaxFeeTolerationUSD is the maximum fee to tolerate for a single + // transaction, in USD. If MaxFeeTolerationUSD <= zero, no maximum is + // enforced. If the payout fee is larger than the toleration then the + // pipeline will pause, checking every 5 seconds, until the fee comes down. + MaxFeeTolerationUSD decimal.Decimal + PromptConfirm func(label string) error } func Preview(ctx context.Context, config Config, db *pipelinedb.DB, paymentPayer payer.Payer) error { - stats, err := db.Stats(ctx) + ethQuote, err := config.Quoter.GetQuote(ctx, coinmarketcap.ETH) if err != nil { return err } @@ -38,37 +52,106 @@ func Preview(ctx context.Context, config Config, db *pipelinedb.DB, paymentPayer return err } - decimals, err := paymentPayer.GetTokenDecimals(ctx) + ethBalance, err := paymentPayer.GetETHBalance(ctx) + if err != nil { + return err + } + + tokenBalance, err := paymentPayer.GetTokenBalance(ctx) if err != nil { return err } - balance, err := paymentPayer.GetTokenBalance(ctx) + gasInfo, err := paymentPayer.GetGasInfo(ctx) if err != nil { return err } - estimatedSTORJ := storjtoken.FromUSD(stats.PendingUSD, storjQuote.Price, decimals) + var ( + ethPricePerWEI = ethQuote.Price.Shift(-18) + gasFeeCapUSD = decimal.NewFromBigInt(gasInfo.GasFeeCap, 0).Mul(ethPricePerWEI) + maxFeeEstimateUSD = gasFeeCapUSD.Mul(decimal.NewFromInt(int64(gasInfo.GasLimit))) + // The threshold divisor normally divides the payout and compares that + // value to the max fee estimate. If smaller than the max fee estimate + // then the payout is too small to justify the fee amount. This + // calculation is the same as multiplying the max fee estimate by the + // threshold divisor and checking if that value exceeds the payout, + // which we'll do here for calculating which payouts are under the + // threshold at this point in time. The payout threshold calculated + // here might be non-positive, which implies no threshold. + payoutThresholdUSD = maxFeeEstimateUSD.Mul(decimal.NewFromInt(int64(config.ThresholdDivisor))) + ) + + stats, err := db.Stats(ctx, payoutThresholdUSD) + if err != nil { + return err + } - fmt.Printf("**PAYMENT TYPE**............: %s\n", paymentPayer) - fmt.Printf("Current STORJ Price.........: $%s\n", storjQuote.Price.String()) + var ( + totalFeesUSD = maxFeeEstimateUSD.Mul(decimal.NewFromInt(stats.TotalPayouts)) + pendingFeesUSD = maxFeeEstimateUSD.Mul(decimal.NewFromInt(stats.PendingPayouts)) + pendingBelowThresholdFeesUSD = maxFeeEstimateUSD.Mul(decimal.NewFromInt(stats.PendingPayoutsBelowThreshold)) + pendingAfterSkippingFeesUSD = maxFeeEstimateUSD.Mul(decimal.NewFromInt(stats.PendingPayouts - stats.PendingPayoutsBelowThreshold)) + pendingAfterSkippingUSD = stats.PendingUSD.Sub(stats.PendingPayoutsBelowThresholdUSD) + + tolerationExceeded = !config.MaxFeeTolerationUSD.IsPositive() || config.MaxFeeTolerationUSD.Cmp(maxFeeEstimateUSD) < 0 + willSkipPayouts = stats.PendingPayoutsBelowThreshold > 0 + + decimals = paymentPayer.Decimals() + storjTotal = storjtoken.FromUSD(stats.TotalUSD, storjQuote.Price, decimals) + storjPending = storjtoken.FromUSD(stats.PendingUSD, storjQuote.Price, decimals) + + ethBalanceETH = decimal.NewFromBigInt(ethBalance, -18) + ) + + fancy.Infof("**PAYMENT TYPE**............................: %s\n", paymentPayer) + fancy.Infof("Current ETH Price...........................: $%s\n", ethQuote.Price) + fancy.Infof("Current STORJ Price.........................: $%s\n", storjQuote.Price) + fancy.Infof("Estimated Per-Gas Fee Cap...................: %s (wei)\n", gasInfo.GasFeeCap) + fancy.Infof("Estimated Per-Gas Fee Tip...................: %s (wei)\n", gasInfo.GasTipCap) + infoOrWarnf(tolerationExceeded, "Max Fee Estimate............................: $%s (for %d gas)\n", maxFeeEstimateUSD.Truncate(5), gasInfo.GasLimit) + infoOrWarnf(tolerationExceeded, "Max Fee Toleration..........................: $%s\n", positiveOrDash(config.MaxFeeTolerationUSD)) + fmt.Println() + fancy.Infof("Total Payees................................: %d\n", stats.Payees) + fancy.Infof("Total Payouts...............................: %d\n", stats.TotalPayouts) + fancy.Infof("Total Payouts USD...........................: $%s\n", stats.TotalUSD) + fancy.Infof("Total Estimated Fees USD....................: $%s\n", totalFeesUSD.Truncate(5)) + fancy.Infof("Total USD...................................: $%s\n", stats.TotalUSD.Add(totalFeesUSD).Truncate(5)) + fmt.Println() + fancy.Infof("Pending Payouts.............................: %d\n", stats.PendingPayouts) + fancy.Infof("Pending Payouts USD.........................: $%s\n", stats.PendingUSD) + fancy.Infof("Pending Estimated Fees USD..................: $%s\n", pendingFeesUSD.Truncate(5)) + fancy.Infof("Pending Total USD...........................: $%s\n", stats.PendingUSD.Add(pendingFeesUSD).Truncate(5)) + fmt.Println() + fancy.Infof("Payouts Threshold..........................: $%s\n", payoutThresholdUSD) + infoOrWarnf(willSkipPayouts, "Pending Payouts Below Threshold.............: %d\n", stats.PendingPayoutsBelowThreshold) + infoOrWarnf(willSkipPayouts, "Pending Payouts Below Threshold USD.........: $%s\n", stats.PendingPayoutsBelowThresholdUSD) + infoOrWarnf(willSkipPayouts, "Pending Payouts Below Threshold Fees USD....: $%s\n", pendingBelowThresholdFeesUSD.Truncate(5)) + infoOrWarnf(willSkipPayouts, "Pending Payouts Below Threshold Total USD...: $%s\n", stats.PendingPayoutsBelowThresholdUSD.Add(pendingBelowThresholdFeesUSD).Truncate(5)) + fmt.Println() + fancy.Infof("Pending Payouts After Skipping..............: %d\n", stats.PendingPayouts-stats.PendingPayoutsBelowThreshold) + fancy.Infof("Pending Payouts After Skipping USD..........: $%s\n", pendingAfterSkippingUSD) + fancy.Infof("Pending After Skipping Estimated Fees USD...: $%s\n", pendingAfterSkippingFeesUSD.Truncate(5)) + fancy.Infof("Pending After Skipping Total USD............: $%s\n", pendingAfterSkippingUSD.Add(pendingAfterSkippingFeesUSD).Truncate(5)) + fmt.Println() + fancy.Infof("Total Fees in ETH...........................: %s\n", totalFeesUSD.Div(ethQuote.Price)) + fancy.Infof("Pending Fees in ETH.........................: %s\n", pendingFeesUSD.Div(ethQuote.Price)) + fancy.Infof("Pending After Skips Fees in ETH.............: %s\n", pendingAfterSkippingFeesUSD.Div(ethQuote.Price)) + fancy.Infof("Current ETH balance ........................: %s\n", ethBalanceETH) fmt.Println() - fmt.Printf("Total Payees................: %d\n", stats.Payees) - fmt.Printf("Total Payouts...............: %d\n", stats.TotalPayouts) - fmt.Printf("Total Payout Groups.........: %d\n", stats.TotalPayoutGroups) - fmt.Printf("Total USD...................: $%s\n", stats.TotalUSD.String()) + fancy.Infof("Total in STORJ ~ ...........................: %s\n", storjtoken.Pretty(storjTotal, decimals)) + fancy.Infof("Pending in STORJ ~ .........................: %s\n", storjtoken.Pretty(storjPending, decimals)) + fancy.Infof("Current STORJ balance ......................: %s\n", storjtoken.Pretty(tokenBalance, decimals)) fmt.Println() - fmt.Printf("Pending Payouts.............: %d\n", stats.PendingPayouts) - fmt.Printf("Pending Payout Groups.......: %d\n", stats.PendingPayoutGroups) - fmt.Printf("Pending USD.................: $%s\n", stats.PendingUSD.String()) - fmt.Printf("Pending in STORJ ~ .........: %s\n", storjtoken.Pretty(estimatedSTORJ, decimals)) - fmt.Printf("Current STORJ balance ......: %s\n", storjtoken.Pretty(balance, decimals)) + fancy.Infof("Pending Payout Groups.......................: %d\n", stats.PendingPayoutGroups) + fancy.Infof("Skipped Payout Groups.......................: %d\n", stats.SkippedPayoutGroups) + fancy.Infof("Total Payout Groups.........................: %d\n", stats.TotalPayoutGroups) fmt.Println() - fmt.Printf("Total Transactions..........: %d\n", stats.TotalTransactions) - fmt.Printf("Pending Transactions........: %d\n", stats.PendingTransactions) - fmt.Printf("Failed Transactions.........: %d\n", stats.FailedTransactions) - fmt.Printf("Confirmed Transactions......: %d\n", stats.ConfirmedTransactions) - fmt.Printf("Dropped Transactions........: %d\n", stats.DroppedTransactions) + fancy.Infof("Total Transactions..........................: %d\n", stats.TotalTransactions) + fancy.Infof("Pending Transactions........................: %d\n", stats.PendingTransactions) + fancy.Infof("Failed Transactions.........................: %d\n", stats.FailedTransactions) + fancy.Infof("Confirmed Transactions......................: %d\n", stats.ConfirmedTransactions) + fancy.Infof("Dropped Transactions........................: %d\n", stats.DroppedTransactions) err = paymentPayer.PrintEstimate(ctx, stats.PendingPayoutGroups) if err != nil { @@ -91,12 +174,14 @@ func Preview(ctx context.Context, config Config, db *pipelinedb.DB, paymentPayer func Run(ctx context.Context, log *zap.Logger, config Config, db *pipelinedb.DB, paymentPayer payer.Payer) error { p, err := pipeline.New(paymentPayer, pipeline.Config{ - Log: log, - Quoter: config.Quoter, - DB: db, - Limit: config.PipelineLimit, - Drain: config.Drain, - TxDelay: config.TxDelay, + Log: log, + Quoter: config.Quoter, + DB: db, + Limit: config.PipelineLimit, + Drain: config.Drain, + TxDelay: config.TxDelay, + ThresholdDivisor: decimal.NewFromInt(int64(config.ThresholdDivisor)), + MaxFeeTolerationUSD: config.MaxFeeTolerationUSD, }) if err != nil { return err @@ -108,3 +193,18 @@ func Run(ctx context.Context, log *zap.Logger, config Config, db *pipelinedb.DB, return nil } + +func positiveOrDash(d decimal.Decimal) string { + if d.IsPositive() { + return d.String() + } + return "-" +} + +func infoOrWarnf(warn bool, format string, args ...any) { + if warn { + fancy.Warnf(format, args...) + return + } + fancy.Infof(format, args...) +} diff --git a/pkg/payouts2/audit.go b/pkg/payouts2/audit.go new file mode 100644 index 0000000..c173bb6 --- /dev/null +++ b/pkg/payouts2/audit.go @@ -0,0 +1,348 @@ +package payouts2 + +import ( + "context" + "errors" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/shopspring/decimal" + "github.com/zeebo/errs" + "golang.org/x/exp/maps" + "storj.io/crypto-batch-payment/pkg/payer" + "storj.io/crypto-batch-payment/pkg/pipelinedb" + "storj.io/crypto-batch-payment/pkg/receipts" +) + +type AuditSink interface { + ReportStatusf(format string, args ...interface{}) + ReportWarnf(format string, args ...interface{}) + ReportErrorf(format string, args ...interface{}) +} + +type TransactionStats struct { + Total int64 + Confirmed int64 + FalseConfirmed int64 + Overpaid int64 + Skipped int64 + Unstarted int64 + Pending int64 + Failed int64 + Dropped int64 + Unknown int64 + DoublePays int64 + MismatchedState int64 + DoublePayStorj big.Int +} + +type DBStats struct { + MissingDBs int + MissingCSVs int + Mismatched int +} + +func AuditDBs(ctx context.Context, dbs map[payer.Type]*pipelinedb.DB, csvPaths []string, sink AuditSink) (*DBStats, error) { + stats := new(DBStats) + + if len(dbs) == 0 { + return nil, errors.New("no databases to audit; have you run 'init'?") + } + + // Make sure each database has the same bonus multipler applied. + bonusMultiplier, err := auditBonusMultiplier(ctx, maps.Values(dbs)) + if err != nil { + sink.ReportErrorf("Invalid bonus multipler: %v", err) + } + + zksyncEraMultiplier := decimal.RequireFromString("1.03") + + csvPayoutsByType, err := loadCSVs(csvPaths, bonusMultiplier, zksyncEraMultiplier, &auditUI{sink: sink}) + if err != nil { + return nil, err + } + + // Determine which payouts type in the CSVs do not have a database + for payerType := range csvPayoutsByType { + if _, ok := dbs[payerType]; !ok { + sink.ReportErrorf("No database for CSV type %q", payerType) + stats.MissingDBs++ + } + } + + // Audit each database + for payerType, db := range dbs { + csvPayouts, ok := csvPayoutsByType[payerType] + if !ok { + sink.ReportErrorf("No CSV for database type %q", payerType) + stats.MissingCSVs++ + continue + } + + dbPayouts, err := db.FetchPayouts(ctx) + if err != nil { + return nil, errs.Wrap(err) + } + + stats.Mismatched += comparePayouts(payerType, csvPayouts, dbPayouts, sink) + } + + return stats, nil +} + +func AuditTransactions(ctx context.Context, payerType payer.Type, auditor payer.Auditor, db *pipelinedb.DB, sink AuditSink, receipts *receipts.Buffer) (*TransactionStats, error) { + sink.ReportStatusf("Fetching payouts...") + dbPayouts, err := db.FetchPayouts(ctx) + if err != nil { + return nil, err + } + + stats := &TransactionStats{ + Total: int64(len(dbPayouts)), + } + + // Confirm the status of each transaction to ensure we haven't accidentally + // overpaid. + sink.ReportStatusf("Confirming TX status...") + txs, err := db.FetchTransactions(ctx) + if err != nil { + return nil, err + } + + last := time.Now() + for i, tx := range txs { + which := i + 1 + now := time.Now() + if which == len(txs) || now.Sub(last) > time.Second { + last = now + sink.ReportStatusf("Confirming TX status (%d/%d)...", which, len(txs)) + } + state, err := auditor.CheckTransactionState(ctx, tx.Hash) + if err != nil { + return nil, err + } + + if tx.State == state { + continue + } + + if tx.State == pipelinedb.TxDropped && state == pipelinedb.TxConfirmed { + sink.ReportErrorf("Double pay for payout group %d (tokens=%s)", tx.PayoutGroupID, tx.StorjTokens) + stats.DoublePays++ + stats.DoublePayStorj.Add(&stats.DoublePayStorj, tx.StorjTokens) + } else { + sink.ReportErrorf("TX state mismatch on hash %q (db=%q, node=%q)", tx.Hash, tx.State, state) + stats.MismatchedState++ + } + } + + // For each payout, ensure it belongs to a payout group with a confirmed + // transaction. Reconfirm the transaction against the blockchain. + sink.ReportStatusf("Checking payouts status...") + payoutGroupStatus := make(map[int64]string) + var payoutsConfirmed int64 + for _, dbPayout := range dbPayouts { + if txHash, ok := payoutGroupStatus[dbPayout.PayoutGroupID]; ok { + if txHash != "" { + receipts.Emit(dbPayout.Payee, dbPayout.USD, txHash, payerType) + } + continue + } + // Mark the payout group status as done with no transaction. It will be + // marked with the confirming transaction after passing the checks below. + payoutGroupStatus[dbPayout.PayoutGroupID] = "" + + numPayouts, err := db.FetchPayoutGroupPayoutCount(ctx, dbPayout.PayoutGroupID) + if err != nil { + return nil, errs.Wrap(err) + } + + status, err := db.FetchPayoutGroupStatus(ctx, dbPayout.PayoutGroupID) + if err != nil { + return nil, errs.Wrap(err) + } + if status == pipelinedb.PayoutGroupSkipped { + stats.Skipped++ + continue + } + + txs, err := db.FetchPayoutGroupTransactions(ctx, dbPayout.PayoutGroupID) + if err != nil { + return nil, errs.Wrap(err) + } + if len(txs) == 0 { + sink.ReportErrorf("Payout (%s) of %s to %s has no attempted transactions", + payerType, dbPayout.USD, dbPayout.Payee.String()) + stats.Unstarted += numPayouts + continue + } + + var pending []*pipelinedb.Transaction + var dropped []*pipelinedb.Transaction + var failed []*pipelinedb.Transaction + var confirmed []*pipelinedb.Transaction + for _, tx := range txs { + switch tx.State { + case pipelinedb.TxPending: + pending = append(pending, tx) + case pipelinedb.TxDropped: + dropped = append(dropped, tx) + case pipelinedb.TxFailed: + failed = append(failed, tx) + case pipelinedb.TxConfirmed: + confirmed = append(confirmed, tx) + default: + sink.ReportErrorf("Unexpected tx state %q on %s", tx.State, tx.Hash) + } + } + + if len(confirmed) == 0 { + sink.ReportErrorf("Payout of %s to %s has no confirmed transactions (pending=%d dropped=%d failed=%d)", + dbPayout.USD, dbPayout.Payee.String(), + len(pending), len(dropped), len(failed)) + switch { + case len(pending) > 0: + stats.Pending += numPayouts + case len(failed) > 0: + stats.Failed += numPayouts + case len(dropped) > 0: + stats.Dropped += numPayouts + default: + stats.Unknown += numPayouts + } + continue + } + + var confirmedCount int + for _, tx := range confirmed { + state, err := auditor.CheckConfirmedTransactionState(ctx, tx.Hash) + switch { + case err != nil: + sink.ReportErrorf("Failed to get receipt for transaction %s for payout of %s to %s", + tx.Hash, dbPayout.USD, dbPayout.Payee.String()) + case state != pipelinedb.TxConfirmed: + sink.ReportErrorf("Transaction %s was %s instead of confirmed for payout of %s to %s", + tx.Hash, state, dbPayout.USD, dbPayout.Payee.String()) + default: + confirmedCount++ + } + } + + if confirmedCount > 0 { + txHash := confirmed[0].Hash + payoutGroupStatus[dbPayout.PayoutGroupID] = txHash + receipts.Emit(dbPayout.Payee, dbPayout.USD, txHash, payerType) + payoutsConfirmed += numPayouts + } + + switch { + case confirmedCount > 1: + sink.ReportErrorf("Payout of %s to %s has more than one (%d) confirmed transactions recorded", + dbPayout.USD, dbPayout.Payee.String(), + len(confirmed)) + stats.Overpaid += numPayouts + case confirmedCount == 0: + stats.FalseConfirmed += numPayouts + default: + stats.Confirmed += numPayouts + } + } + + return stats, nil +} + +func auditBonusMultiplier(ctx context.Context, dbs []*pipelinedb.DB) (decimal.Decimal, error) { + if len(dbs) == 0 { + return decimal.Decimal{}, nil + } + + first, err := dbs[0].GetBonusMultiplier(ctx) + if err != nil { + return decimal.Decimal{}, err + } + + for _, db := range dbs[1:] { + other, err := db.GetBonusMultiplier(ctx) + if err != nil { + return decimal.Decimal{}, err + } + if !first.Equal(other) { + return decimal.Decimal{}, errs.New("mismatched bonus multipler: expected %q but got %q", first, other) + } + } + return first, nil +} + +// comparePayouts compares the csv and db payouts contents and returns the number of mismatched payouts. +func comparePayouts(payerType payer.Type, csvPayouts, dbPayouts []*pipelinedb.Payout, sink AuditSink) int { + csvPayoutsByPayee := make(map[common.Address]*pipelinedb.Payout) + for _, csvPayout := range csvPayouts { + if _, ok := csvPayoutsByPayee[csvPayout.Payee]; ok { + // This would only happen if there was a bug loading payouts from CSV + sink.ReportErrorf("Duplicate %s payee %s detected in CSV payouts", payerType, csvPayout.Payee) + } + csvPayoutsByPayee[csvPayout.Payee] = csvPayout + } + dbPayoutsByPayee := make(map[common.Address]*pipelinedb.Payout) + for _, dbPayout := range dbPayouts { + if _, ok := dbPayoutsByPayee[dbPayout.Payee]; ok { + // This would only happen if there was a bug loading payouts from CSV + sink.ReportErrorf("Duplicate %s CSV payee %s detected in database payouts", payerType, dbPayout.Payee) + } + dbPayoutsByPayee[dbPayout.Payee] = dbPayout + } + + mismatched := map[common.Address]struct{}{} + + // Ensure each CSV payout is represented accurately in the DB + sink.ReportStatusf("Reconciling %s CSV payout entries...", payerType) + for _, csvPayout := range csvPayouts { + dbPayout, ok := dbPayoutsByPayee[csvPayout.Payee] + if !ok { + sink.ReportErrorf("No %s payout for payee %s in database", payerType, csvPayout.Payee) + mismatched[csvPayout.Payee] = struct{}{} + continue + } + if !dbPayout.USD.Equal(csvPayout.USD) { + sink.ReportErrorf("Amount mismatch for %s payee %s: csv=%q db=%q", payerType, csvPayout.Payee, csvPayout.USD, dbPayout.USD) + mismatched[csvPayout.Payee] = struct{}{} + continue + } + } + + // Ensure each DB payout is represented accurately in the CSV + sink.ReportStatusf("Reconciling %s DB payout entries...", payerType) + for _, dbPayout := range dbPayouts { + if _, ok := csvPayoutsByPayee[dbPayout.Payee]; !ok { + sink.ReportErrorf("No %s payout for payee %s in CSV", payerType, dbPayout.Payee) + mismatched[dbPayout.Payee] = struct{}{} + } + } + + return len(mismatched) +} + +type auditUI struct { + sink AuditSink +} + +func (a *auditUI) Started(evt StartedEvent) { + a.sink.ReportStatusf("Auditing CSVs: %q", evt.CSVPaths) +} + +func (a *auditUI) CSVLoaded(evt CSVLoadedEvent) { + if evt.Err != nil { + a.sink.ReportStatusf("Failed to load %q: %v", evt.CSVPath, evt.Err) + } else { + a.sink.ReportStatusf("Loaded %q (%d rows)", evt.CSVPath, evt.NumRows) + } +} + +func (a *auditUI) RowAggregated(_ RowAggregatedEvent) {} + +func (a *auditUI) RowSkipped(_ RowSkippedEvent) {} + +func (a *auditUI) RowsAggregated(_ RowsAggregatedEvent) {} + +func (a *auditUI) CSVsLoaded(_ CSVsLoadedEvent) {} diff --git a/pkg/payouts2/init.go b/pkg/payouts2/init.go new file mode 100644 index 0000000..b0cf7b3 --- /dev/null +++ b/pkg/payouts2/init.go @@ -0,0 +1,213 @@ +package payouts2 + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/ethereum/go-ethereum/common" + "github.com/shopspring/decimal" + "github.com/zeebo/errs" + "storj.io/crypto-batch-payment/pkg/payer" + "storj.io/crypto-batch-payment/pkg/pipelinedb" + "storj.io/crypto-batch-payment/pkg/prepayoutcsv" +) + +type InitParams struct { + CSVPaths []string + BonusMultiplier decimal.Decimal + ZksyncEraMultiplier decimal.Decimal +} + +func Init(ctx context.Context, params InitParams, ui InitUI) error { + if len(params.CSVPaths) == 0 { + return errors.New("prepayout CSVs are required to initialize payouts") + } + + byType, err := loadCSVs(params.CSVPaths, params.BonusMultiplier, params.ZksyncEraMultiplier, ui) + if err != nil { + return err + } + + for payerType, payouts := range byType { + if err := initDB(ctx, params.BonusMultiplier, params.ZksyncEraMultiplier, payerType, payouts); err != nil { + return err + } + } + + return nil +} + +func loadCSVs(csvPaths []string, bonusMultiplier, zksyncEraMultiplier decimal.Decimal, ui InitUI) (ByType, error) { + ui.Started(StartedEvent{CSVPaths: csvPaths}) + + aggregation := new(payoutAggregation) + + var loadFailed bool + for _, csvPath := range csvPaths { + prepayoutRows, err := prepayoutcsv.Load(csvPath) + if err != nil { + ui.CSVLoaded(CSVLoadedEvent{CSVPath: csvPath, Err: err}) + loadFailed = true + continue + } + ui.CSVLoaded(CSVLoadedEvent{CSVPath: csvPath, NumRows: len(prepayoutRows)}) + + for _, prepayoutRow := range prepayoutRows { + switch { + // Filter out rows with invalid addresses + case prepayoutRow.Address == (common.Address{}): + ui.RowSkipped(RowSkippedEvent{CSVPath: csvPath, Line: prepayoutRow.Line, Reason: RowInvalid}) + continue + // Filter out sanctioned rows + case prepayoutRow.Sanctioned: + ui.RowSkipped(RowSkippedEvent{CSVPath: csvPath, Line: prepayoutRow.Line, Reason: RowSanctioned}) + continue + } + + typ, err := payer.TypeFromString(prepayoutRow.Kind) + if err != nil { + return nil, errs.New("invalid kind %q: %v", typ, err) + } + + amount := prepayoutRow.Amount + if prepayoutRow.Bonus { + amount = amount.Mul(bonusMultiplier) + } + if typ == payer.ZkSyncEra { + amount = amount.Mul(zksyncEraMultiplier) + } + + aggregation.Add(typ, pipelinedb.Payout{ + Payee: prepayoutRow.Address, + USD: amount, + Mandatory: prepayoutRow.Mandatory, + }) + + ui.RowAggregated(RowAggregatedEvent{CSVPath: csvPath, Line: prepayoutRow.Line}) + } + + ui.RowsAggregated(RowsAggregatedEvent{CSVPath: csvPath}) + } + + if loadFailed { + return nil, errs.New("failed to load one or more prepayout CSVs") + } + + byType := aggregation.Finalize() + + ui.CSVsLoaded(CSVsLoadedEvent{ + ByType: byType, + }) + + return byType, nil +} + +type ByType map[payer.Type][]*pipelinedb.Payout + +type payoutAggregation struct { + byType map[payer.Type]map[common.Address]*pipelinedb.Payout +} + +func (agg *payoutAggregation) Add(payerType payer.Type, payout pipelinedb.Payout) { + if agg.byType == nil { + agg.byType = make(map[payer.Type]map[common.Address]*pipelinedb.Payout) + } + byPayee, ok := agg.byType[payerType] + if !ok { + byPayee = make(map[common.Address]*pipelinedb.Payout) + agg.byType[payerType] = byPayee + } + + if existing, ok := byPayee[payout.Payee]; ok { + payout.USD = payout.USD.Add(existing.USD) + if !existing.Mandatory { + payout.Mandatory = false + } + } + + byPayee[payout.Payee] = &payout +} + +func (agg *payoutAggregation) Finalize() ByType { + final := make(ByType) + for payerType, byPayee := range agg.byType { + for _, payout := range byPayee { + if !decimal.Zero.LessThan(payout.USD) { + continue + } + final[payerType] = append(final[payerType], payout) + } + } + + // Now sort payouts by address for a given payer type. This is just a + // nice-to-have so that progress through the pipeline can be somewhat + // implied by observing the address space currently being processed. + for _, payouts := range final { + sort.Slice(payouts, func(i, j int) bool { + return bytes.Compare(payouts[i].Payee[:], payouts[j].Payee[:]) < 0 + }) + } + return final +} + +func initDB(ctx context.Context, bonusMultiplier, zksyncEraMultiplier decimal.Decimal, kind payer.Type, payouts []*pipelinedb.Payout) error { + tmpDir, err := os.MkdirTemp(".", "") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + tmpPath := filepath.Join(tmpDir, payoutDBName(kind)) + + db, err := pipelinedb.NewDB(ctx, tmpPath) + if err != nil { + return errs.Wrap(err) + } + defer func() { _ = db.Close() }() + + if err := db.SetBonusMultiplier(ctx, bonusMultiplier); err != nil { + return errs.Wrap(err) + } + + // TODO: set zksyncEraMultiplier + + if err := createPayoutGroups(ctx, db, payouts); err != nil { + return err + } + + if err := db.Close(); err != nil { + return errs.Wrap(err) + } + + if err := os.Rename(tmpPath, payoutDBName(kind)); err != nil { + return errs.Wrap(err) + } + + return nil +} + +func payoutDBName(payerType payer.Type) string { + return fmt.Sprintf("payout.%s.db", payerType) +} + +func createPayoutGroups(ctx context.Context, db *pipelinedb.DB, payouts []*pipelinedb.Payout) error { + const groupSize = 1 + groups := make([][]*pipelinedb.Payout, 0, len(payouts)) + for i := 0; i < len(payouts); { + end := i + groupSize + if end > len(payouts) { + end = len(payouts) + } + groups = append(groups, payouts[i:end]) + i = end + } + if err := db.CreatePayoutGroups(ctx, groups); err != nil { + return err + } + return nil +} diff --git a/pkg/payouts2/initui.go b/pkg/payouts2/initui.go new file mode 100644 index 0000000..257285c --- /dev/null +++ b/pkg/payouts2/initui.go @@ -0,0 +1,59 @@ +package payouts2 + +type InitUI interface { + Started(StartedEvent) + CSVLoaded(CSVLoadedEvent) + RowAggregated(RowAggregatedEvent) + RowSkipped(RowSkippedEvent) + RowsAggregated(RowsAggregatedEvent) + CSVsLoaded(CSVsLoadedEvent) +} + +type StartedEvent struct { + CSVPaths []string +} + +type CSVLoadedEvent struct { + CSVPath string + Err error + NumRows int +} + +type RowAggregatedEvent struct { + CSVPath string + Line int +} + +type RowSkippedEvent struct { + CSVPath string + Line int + Reason RowSkipReason +} + +type RowsAggregatedEvent struct { + CSVPath string +} + +type CSVsLoadedEvent struct { + ByType ByType +} + +type RowSkipReason int + +const ( + RowInvalid RowSkipReason = iota + RowSanctioned + // RowSkipReasonMax must remain at the end. + RowSkipReasonMax +) + +func (r RowSkipReason) String() string { + switch r { + case RowInvalid: + return "invalid" + case RowSanctioned: + return "sanctioned" + default: + return "unknown" + } +} diff --git a/pkg/pipeline/pipeline.go b/pkg/pipeline/pipeline.go index c9ac50f..2800dd1 100644 --- a/pkg/pipeline/pipeline.go +++ b/pkg/pipeline/pipeline.go @@ -4,19 +4,19 @@ package pipeline import ( "context" "encoding/json" + "errors" "math/big" "time" + "storj.io/crypto-batch-payment/pkg/coinmarketcap" "storj.io/crypto-batch-payment/pkg/payer" "storj.io/crypto-batch-payment/pkg/pipelinedb" + "storj.io/crypto-batch-payment/pkg/storjtoken" "github.com/ethereum/go-ethereum/common" "github.com/shopspring/decimal" "github.com/zeebo/errs" "go.uber.org/zap" - - "storj.io/crypto-batch-payment/pkg/coinmarketcap" - "storj.io/crypto-batch-payment/pkg/storjtoken" ) const ( @@ -40,8 +40,18 @@ const ( var ( // zero is a big int set to 0 for convenience. zero = big.NewInt(0) + + // errSkipped is returned from prepareTransaction when the + // transaction fees are too high relative to the payout amount. + errSkipped = errors.New("max fee exceeded") + + // errMaxFeeExceeded is returned from prepareTransaction when the + // transaction is estimated to exceed the MaxFeeTolerationUSD in fees. + errMaxFeeExceeded = errors.New("max fee exceeded") ) +type sleepFunc = func(context.Context, time.Duration) error + type Config struct { // Log is the logger for logging pipeline progress Log *zap.Logger @@ -55,6 +65,16 @@ type Config struct { // Quoter is used to get price quotes for STORJ token Quoter coinmarketcap.Quoter + // ThresholdDivisor divides a payout amount to determine the fee threshold. + // If a payout fee is larger than the fee threshold then the payout is + // skipped. If ThresholdDivisor <= 0, no payouts will be skipped. + ThresholdDivisor decimal.Decimal + + // MaxFeeTolerationUSD is the maximum fee to tolerate for a single + // transaction, in USD. If MaxFeeTolerationUSD <= zero, no maximum is + // enforced. + MaxFeeTolerationUSD decimal.Decimal + // DB is the the payout database DB *pipelinedb.DB @@ -79,42 +99,62 @@ type Config struct { // test hook used to manipulate the polling interval pollInterval time.Duration + + // test hook used for sleeping so we aren't dependent on real time + sleep sleepFunc } type Pipeline struct { log *zap.Logger - owner common.Address - quoter coinmarketcap.Quoter - db *pipelinedb.DB - limit int - txDelay time.Duration - drain bool - payer payer.Payer + owner common.Address + quoter coinmarketcap.Quoter + thresholdDivisor decimal.Decimal + maxFeeTolerationUSD decimal.Decimal + db *pipelinedb.DB + limit int + txDelay time.Duration + drain bool + payer payer.Payer pollInterval time.Duration + sleep sleepFunc expectedNonce uint64 nonceGroups []*pipelinedb.NonceGroup } func New(payer payer.Payer, config Config) (*Pipeline, error) { + switch { + case config.Log == nil: + return nil, errors.New("log is required") + case config.Quoter == nil: + return nil, errors.New("quoter is required") + case config.DB == nil: + return nil, errors.New("db is required") + } if config.Limit == 0 { config.Limit = DefaultLimit } if config.pollInterval == 0 { config.pollInterval = txStatusPollInterval } + if config.sleep == nil { + config.sleep = sleep + } return &Pipeline{ - log: config.Log, - owner: config.Owner, - quoter: config.Quoter, - db: config.DB, - limit: config.Limit, - txDelay: config.TxDelay, - drain: config.Drain, - pollInterval: config.pollInterval, - payer: payer, + log: config.Log, + owner: config.Owner, + quoter: config.Quoter, + thresholdDivisor: config.ThresholdDivisor, + maxFeeTolerationUSD: config.MaxFeeTolerationUSD, + db: config.DB, + limit: config.Limit, + txDelay: config.TxDelay, + drain: config.Drain, + pollInterval: config.pollInterval, + sleep: config.sleep, + payer: payer, }, nil } @@ -125,8 +165,7 @@ func (p *Pipeline) ProcessPayouts(ctx context.Context) error { zap.Bool("drain", p.drain), ) - err := p.initPayout(ctx) - if err != nil { + if err := p.initPayout(ctx); err != nil { return err } @@ -176,7 +215,7 @@ func (p *Pipeline) initPayout(ctx context.Context) error { func (p *Pipeline) payoutStep(ctx context.Context) (bool, error) { // Trim off nonce groups that have no more transactions. This only - // happens when a nonce group has been confirmed or failed. + // happens when a nonce group has been confirmed, failed or is being skipped. var finished int for len(p.nonceGroups) > 0 && len(p.nonceGroups[0].Txs) == 0 { p.log.Info("Nonce group finished", zap.Uint64("nonce", p.nonceGroups[0].Nonce)) @@ -187,24 +226,57 @@ func (p *Pipeline) payoutStep(ctx context.Context) (bool, error) { p.log.Info("Pipeline status", zap.Int("len", len(p.nonceGroups)), zap.Int("limit", p.limit)) } + added, err := p.fillPipeline(ctx) + if err != nil { + return true, err + } + + // Pipeline is empty + if len(p.nonceGroups) == 0 { + if p.drain { + p.log.Info("Drained existing transactions.") + } else { + p.log.Info("Processed all payout groups") + } + return true, nil + } + + if added { + unfinishedPayouts, totalPayouts, err := p.db.FetchPayoutProgress(ctx) + if err != nil { + return true, err + } + p.log.Info("Waiting on nonce groups...", + zap.Int("pending", len(p.nonceGroups)), + zap.Int64("unfinished payouts", unfinishedPayouts), + zap.Int64("total payouts", totalPayouts), + ) + } + + done, err := p.checkNonceGroups(ctx) + if done { + return true, err + } + return false, nil +} + +func (p *Pipeline) fillPipeline(ctx context.Context) (_ bool, err error) { + if len(p.nonceGroups) >= p.limit || p.drain { + return false, nil + } + // Fill up the pipeline - var added bool - for i := 0; len(p.nonceGroups) < p.limit && !p.drain; i++ { + var added int + for i := 0; len(p.nonceGroups) < p.limit; i++ { payoutGroup, err := p.db.FetchFirstUnfinishedUnattachedPayoutGroup(ctx) if err != nil { - return true, err + return false, err } if payoutGroup == nil { // no payout groups to add break } - if i > 0 && p.txDelay > 0 { - if err := sleepFor(ctx, p.txDelay); err != nil { - return true, err - } - } - // Either continue with the next nonce (if there is a nonce group // to increment from) or grab the account nonce according to the // blockchain. @@ -215,7 +287,7 @@ func (p *Pipeline) payoutStep(ctx context.Context) (bool, error) { } else { nextNonce, err = p.payer.NextNonce(ctx) if err != nil { - return true, errs.New("unable to obtain next nonce from blockchain: %v", err) + return false, errs.New("unable to obtain next nonce from blockchain: %v", err) } p.log.Info("Nonce from chain", zap.Uint64("nextNonce", nextNonce)) // NonceAt can return an earlier nonce than expected if the @@ -227,52 +299,34 @@ func (p *Pipeline) payoutStep(ctx context.Context) (bool, error) { // TODO: we could spin here for a time until the node returns // the expected nonce... if p.expectedNonce > 0 && nextNonce < p.expectedNonce { - return true, errs.New("node returned used nonce %d; expected >= %d", nextNonce, p.expectedNonce) + return false, errs.New("node returned used nonce %d; expected >= %d", nextNonce, p.expectedNonce) } - p.expectedNonce = nextNonce + 1 } tx, err := p.sendTransaction(ctx, payoutGroup.ID, nextNonce) - if err != nil { - return true, err + switch { + case errors.Is(err, errSkipped): + continue + case err != nil: + return false, err } + p.expectedNonce = nextNonce + 1 p.nonceGroups = append(p.nonceGroups, &pipelinedb.NonceGroup{ Nonce: tx.Nonce, PayoutGroupID: payoutGroup.ID, Txs: []pipelinedb.Transaction{*tx}, }) p.log.Debug("Pipeline status", zap.Int("len", len(p.nonceGroups)), zap.Int("limit", p.limit)) - added = true - } - // Pipeline is empty - if len(p.nonceGroups) == 0 { - if p.drain { - p.log.Info("Drained existing transactions.") - } else { - p.log.Info("Processed all payout groups") + if err := sleepFor(ctx, p.txDelay); err != nil { + return false, err } - return true, nil - } - if added { - unfinishedPayouts, totalPayouts, err := p.db.FetchPayoutProgress(ctx) - if err != nil { - return true, err - } - p.log.Info("Waiting on nonce groups...", - zap.Int("pending", len(p.nonceGroups)), - zap.Int64("unfinished payouts", unfinishedPayouts), - zap.Int64("total payouts", totalPayouts), - ) + added++ } - done, err := p.checkNonceGroups(ctx) - if done { - return true, err - } - return false, nil + return added > 0, nil } func (p *Pipeline) checkNonceGroups(ctx context.Context) (bool, error) { @@ -306,11 +360,17 @@ checkLoop: // All the transactions have been dropped. Send another transaction for // this nonce group. Indicate to the caller that there was a drop. tx, err := p.sendTransaction(ctx, p.nonceGroups[i].PayoutGroupID, p.nonceGroups[i].Nonce) - if err != nil { + switch { + case errors.Is(err, errSkipped): + // Cannot retry the nonce group since it has dropped below + // the fee threshold. + p.nonceGroups[i].Txs = nil + case err != nil: return true, err + default: + p.nonceGroups[i].Txs = append(p.nonceGroups[i].Txs, *tx) + break checkLoop } - p.nonceGroups[i].Txs = append(p.nonceGroups[i].Txs, *tx) - break checkLoop case pipelinedb.TxPending: // This group has not confirmed/failed. Don't look at the rest // until we know it's fate. It is dangerous to look further @@ -340,53 +400,63 @@ checkLoop: } func (p *Pipeline) sendTransaction(ctx context.Context, payoutGroupID int64, nonce uint64) (*pipelinedb.Transaction, error) { + for { + tx, err := p.trySendTransaction(ctx, payoutGroupID, nonce) + switch { + case errors.Is(err, errMaxFeeExceeded): + // handled below + case err != nil: + return nil, err + default: + return tx, nil + } + + p.log.Info("Max fee toleration was exceeded; waiting 5 seconds before trying again") + if err := p.sleep(ctx, 5*time.Second); err != nil { + return nil, err + } + } +} + +func (p *Pipeline) trySendTransaction(ctx context.Context, payoutGroupID int64, nonce uint64) (*pipelinedb.Transaction, error) { + txLog := p.log.With( + zap.Int64("payout-group-id", payoutGroupID), + zap.Stringer("owner", p.owner), + zap.Uint64("nonce", nonce), + ) + payouts, err := p.db.FetchPayoutGroupPayouts(ctx, payoutGroupID) - if err != nil { + switch { + case err != nil: return nil, err - } - if len(payouts) == 0 { + case len(payouts) == 0: return nil, errs.New("no payouts associated with transfer %d", payoutGroupID) + case len(payouts) > 1: + return nil, errs.New("multitransfer is not supported") } - sumUSD := decimal.Zero - for _, p := range payouts { - sumUSD = decimal.Sum(sumUSD, p.USD) - } + payout := payouts[0] - for { - unmet, err := p.payer.CheckPreconditions(ctx) - if err != nil { - return nil, err - } - if len(unmet) == 0 { - break - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(5 * time.Second): - p.log.Info("One or more preconditions are not met, waiting for 5 seconds", zap.Strings("unmet", unmet)) - } - } + txLog = txLog.With(zap.Stringer("usd", payout.USD)) - decimals, err := p.payer.GetTokenDecimals(ctx) + ethPrice, err := p.getQuote(ctx, coinmarketcap.ETH) if err != nil { return nil, err } + txLog = txLog.With(zap.Stringer("eth-price", ethPrice)) - storjPrice, err := p.getStorjPrice(ctx) + // Calculate how many STORJ tokens are required to cover the payout. + storjPrice, err := p.getQuote(ctx, coinmarketcap.STORJ) if err != nil { return nil, err } + txLog = txLog.With(zap.Stringer("storj-price", storjPrice)) + + storjTokens := storjtoken.FromUSD(payout.USD, storjPrice, p.payer.Decimals()) + txLog = txLog.With(zap.Stringer("storj-tokens", storjTokens)) - storjTokens := storjtoken.FromUSD(sumUSD, storjPrice, decimals) if storjTokens.Cmp(zero) <= 0 { - p.log.Error("STORJ token amount must be greater than zero", - zap.Int64("payout group", payoutGroupID), - zap.String("usd", sumUSD.String()), - zap.String("price", storjPrice.String()), - zap.String("tokens", storjTokens.String()), - ) + txLog.Error("STORJ token amount must be greater than zero") return nil, errs.New("cannot transfer %s tokens for payout group %d: must be more than zero", storjTokens, payoutGroupID) } @@ -395,23 +465,55 @@ func (p *Pipeline) sendTransaction(ctx context.Context, payoutGroupID int64, non if err != nil { return nil, err } + txLog = txLog.With(zap.Stringer("storj-balance", storjBalance)) + if storjBalance.Cmp(storjTokens) < 0 { return nil, errs.New("not enough STORJ balance to cover transfer (%s < %s)", storjBalance, storjTokens) } - txLog := p.log.With( - zap.Uint64("nonce", nonce), - zap.Int64("payout-group-id", payoutGroupID), - zap.String("owner", p.owner.String()), - zap.String("storj-price", storjPrice.String()), - zap.String("storj-tokens", storjTokens.String()), - zap.String("storj-balance", storjBalance.String())) + params := payer.TransactionParams{ + Nonce: nonce, + Payee: payout.Payee, + Tokens: storjTokens, + } - rawTx, from, err := p.payer.CreateRawTransaction(ctx, txLog, payouts, nonce, storjPrice) + rawTx, from, err := p.payer.CreateRawTransaction(ctx, txLog, params) if err != nil { return nil, err } + maxFeeGWEI := decimal.NewFromInt(int64(rawTx.EstimatedGasLimit)).Mul(decimal.NewFromBigInt(rawTx.EstimatedGasFeeCap, 0)) + maxFeeUSD := ethPrice.Mul(maxFeeGWEI).Shift(-18) + + txLog = txLog.With( + zap.Uint64("estimated-gas-limit", rawTx.EstimatedGasLimit), + zap.Stringer("estimated-gas-fee-cap", rawTx.EstimatedGasFeeCap), + zap.Stringer("max-fee-usd", maxFeeUSD), + ) + + if p.maxFeeTolerationUSD.IsPositive() && p.maxFeeTolerationUSD.Cmp(maxFeeUSD) < 0 { + txLog.Warn("Payout max fee exceeds the max fee toleration", zap.Stringer("max-fee-toleration-usd", p.maxFeeTolerationUSD)) + return nil, errMaxFeeExceeded + } + + if p.thresholdDivisor.IsPositive() { + thresholdUSD := payout.USD.Div(p.thresholdDivisor) + // This is where we'd check if the payment is mandatory, and only skip + // if not, but mandatoriness is not being honored at this time. + if thresholdUSD.Cmp(maxFeeUSD) < 0 { + txLog.Warn("Skipping transaction because payout is below the minimum payout threshold", zap.Stringer("threshold-usd", thresholdUSD)) + if err := p.db.SetPayoutGroupStatus(ctx, payoutGroupID, pipelinedb.PayoutGroupSkipped); err != nil { + return nil, errs.Wrap(err) + } + return nil, errSkipped + } + } + + // Clear the payout group status + if err := p.db.SetPayoutGroupStatus(ctx, payoutGroupID, ""); err != nil { + return nil, errs.Wrap(err) + } + rawTxJSON, err := json.Marshal(rawTx.Raw) if err != nil { return nil, errs.Wrap(err) @@ -433,24 +535,30 @@ func (p *Pipeline) sendTransaction(ctx context.Context, payoutGroupID int64, non return nil, err } - err = p.payer.SendTransaction(ctx, txLog, rawTx) - return tx, err + if err := p.payer.SendTransaction(ctx, txLog, rawTx); err != nil { + return nil, err + } + return tx, nil } -func (p *Pipeline) getStorjPrice(ctx context.Context) (decimal.Decimal, error) { - storjQuote, err := p.quoter.GetQuote(ctx, coinmarketcap.STORJ) +func (p *Pipeline) getQuote(ctx context.Context, symbol coinmarketcap.Symbol) (decimal.Decimal, error) { + storjQuote, err := p.quoter.GetQuote(ctx, symbol) if err != nil { - return decimal.Decimal{}, err + return decimal.Decimal{}, errs.Wrap(err) } return storjQuote.Price, nil } func sleepFor(ctx context.Context, d time.Duration) error { + if d <= 0 { + return nil + } timer := time.NewTimer(d) select { case <-timer.C: return nil case <-ctx.Done(): + timer.Stop() return ctx.Err() } } @@ -465,3 +573,19 @@ func youngestTransactionTime(txs []pipelinedb.Transaction) time.Time { } return youngest } + +func safeBigInt(b *big.Int) string { + if b == nil { + return "unset" + } + return b.String() +} + +func sleep(ctx context.Context, duration time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(5 * time.Second): + return nil + } +} diff --git a/pkg/pipeline/pipeline_eth_test.go b/pkg/pipeline/pipeline_eth_test.go index 547b31f..338a105 100644 --- a/pkg/pipeline/pipeline_eth_test.go +++ b/pkg/pipeline/pipeline_eth_test.go @@ -8,29 +8,27 @@ import ( "testing" "time" - batchpayment "storj.io/crypto-batch-payment/pkg" - + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient/simulated" - - "storj.io/crypto-batch-payment/pkg/eth" - "storj.io/crypto-batch-payment/pkg/pipelinedb" - - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" + batchpayment "storj.io/crypto-batch-payment/pkg" "storj.io/crypto-batch-payment/pkg/coinmarketcap" "storj.io/crypto-batch-payment/pkg/contract" + "storj.io/crypto-batch-payment/pkg/eth" "storj.io/crypto-batch-payment/pkg/ethtest" + "storj.io/crypto-batch-payment/pkg/pipelinedb" ) const ( - initialStorj int64 = 1e11 + initialStorj = 1e11 + testChainID = 1337 ) var ( @@ -515,7 +513,7 @@ func TestPipelineUsesLatestGasEstimateAndStorjPrice(t *testing.T) { } func TestPipelineTransferFrom(t *testing.T) { - gasTipCap := big.NewInt(1) + gasTipCap := eth.UnitFromInt(1, eth.GWEI) test := NewPipelineTest(t, WithSpender(spender), WithGasTipCap(gasTipCap)) test.Approve(owner, spender, big.NewInt(1e8)) @@ -560,7 +558,6 @@ func TestPipelineTransferFrom(t *testing.T) { test.commit() return false, nil case 2: - txHash, err := batchpayment.HashFromString(tx.Hash) test.R.NoError(err) @@ -580,8 +577,11 @@ func TestPipelineTransferFrom(t *testing.T) { newBalance, err := client.BalanceAt(ctx, spender.Address, nil) test.R.NoError(err) + t.Logf("block baseFee: %s", block.BaseFee()) + t.Logf("block baseFee: %s", block.BaseFee()) + // make sure only gas * (baseFee + tip) is used - gasCost := new(big.Int).Add(block.BaseFee(), gasTipCap) + gasCost := new(big.Int).Add(block.BaseFee(), gasTipCap.WEIInt()) cost := new(big.Int).Mul(gasCost, big.NewInt(int64(receipt.GasUsed))) test.R.Equal(new(big.Int).Sub(lastBalance, cost), newBalance) @@ -717,6 +717,102 @@ func TestPipelineTooSmallPayment(t *testing.T) { test.AssertProcessPayoutsFails("cannot transfer 0 tokens for payout group 1: must be more than zero") } +func TestPipelinePaymentBelowThreshold(t *testing.T) { + // The payout is for $1.00. With a threshold divisor of 4, the payout will + // be skipped if the transaction cost exceeds $0.25. + // Our fake transaction gas limit is 70000 gas. With a gas fee cap of 4 + // GWEI (per gas), the most this transaction will cost is 280,000 GWEI + // (0.000280000 ETH). At $1000 ETH price, that comes to $0.28, which + // exceeds the limit and marks the payout as "skipped". + gasFeeCap := eth.RequireParseUnit("4.000gwei") + gasTipCap := eth.RequireParseUnit("0.001gwei") + + test := NewPipelineTest(t, + WithGasFeeCap(gasFeeCap), + WithGasTipCap(gasTipCap), + WithThresholdDivisor(4), + ) + test.SetETHPrice("1000") + test.SetStorjPrice("1") + + test.InitializePayoutGroups([]*pipelinedb.Payout{ + { + Payee: alice.Address, + USD: decimal.RequireFromString("1.00"), + }, + }) + + test.ProcessPayouts(func(step int, pipeline []*pipelinedb.NonceGroup, cancel func()) (bool, error) { + switch step { + case 0: + // Only payout was skipped + test.R.Empty(pipeline) + return true, nil + default: + test.Fatalf("not expecting step %d", step) + return false, nil + } + }) + + assert.Equal(t, pipelinedb.PayoutGroupSkipped, test.FetchPayoutGroupStatus(1)) +} + +func TestPipelinePaymentExceedsMaxFeeToleration(t *testing.T) { + // The payout is for $1.00. Our fake transaction gas limit is 70000 gas. + // With a gas fee cap of 4 GWEI (per gas), the most this transaction will + // cost is 280,000 GWEI (0.000280000 ETH). At $1000 ETH price, that comes + // to $0.28. We will set the max fee toleration at 25 cents and then the + // pipeline sleeps, drop the ETH price to $860 to drop the max fee below + // the toleration + gasFeeCap := eth.RequireParseUnit("4.000gwei") + gasTipCap := eth.RequireParseUnit("0.001gwei") + maxFeeTolerationUSD := decimal.RequireFromString("0.25") + + test := NewPipelineTest(t, + WithGasFeeCap(gasFeeCap), + WithGasTipCap(gasTipCap), + WithMaxFeeTolerationUSD(maxFeeTolerationUSD), + ) + test.SetETHPrice("1000") + test.SetStorjPrice("1") + + var slept bool + test.AfterSleep(func() { + slept = true + test.SetETHPrice("860") + }) + + test.InitializePayoutGroups([]*pipelinedb.Payout{ + { + Payee: alice.Address, + USD: decimal.RequireFromString("1.00"), + }, + }) + + test.ProcessPayouts(func(step int, pipeline []*pipelinedb.NonceGroup, cancel func()) (bool, error) { + switch step { + case 0: + // Pipeline just started with no existing nonce groups + test.R.Empty(pipeline) + return false, nil + case 1: + test.R.Len(pipeline, 1) + test.R.Len(pipeline[0].Txs, 1) + test.commit() + return false, nil + case 2: + // in this step it was determined that the tx was confirmed + return true, nil + default: + test.Fatalf("not expecting step %d", step) + return false, nil + } + }) + + assert.True(t, slept, "pipeline didn't sleep while waiting for the max fee to drop below the toleration") + assert.Equal(t, pipelinedb.PayoutGroupComplete, test.FetchPayoutGroupStatus(1)) +} + ///////////////////////////////////////////////////////////////////////////// // Helpers ///////////////////////////////////////////////////////////////////////////// @@ -735,9 +831,27 @@ func WithSpender(spender *ethtest.Account) PipelineTestOption { } } -func WithGasTipCap(gasTipCap *big.Int) PipelineTestOption { +func WithGasFeeCap(gasFeeCap eth.Unit) PipelineTestOption { + return func(c *PipelineTest) { + c.gasFeeCap = &gasFeeCap + } +} + +func WithGasTipCap(gasTipCap eth.Unit) PipelineTestOption { + return func(c *PipelineTest) { + c.gasTipCap = &gasTipCap + } +} + +func WithThresholdDivisor(divisor int) PipelineTestOption { return func(c *PipelineTest) { - c.gasTipCap = gasTipCap + c.thresholdDivisor = decimal.NewFromInt(int64(divisor)) + } +} + +func WithMaxFeeTolerationUSD(maxFeeTolerationUSD decimal.Decimal) PipelineTestOption { + return func(c *PipelineTest) { + c.maxFeeTolerationUSD = maxFeeTolerationUSD } } @@ -747,10 +861,14 @@ type PipelineTest struct { R *require.Assertions // config - limit int - spender *ethtest.Account - gasTipCap *big.Int - maxGas *big.Int + limit int + spender *ethtest.Account + gasFeeCap *eth.Unit + gasTipCap *eth.Unit + thresholdDivisor decimal.Decimal + maxFeeTolerationUSD decimal.Decimal + + afterSleep []func() DB *pipelinedb.DB @@ -787,12 +905,18 @@ func NewPipelineTest(t *testing.T, opts ...PipelineTestOption) *PipelineTest { test.initNetwork() - headBlock, err := test.Client.BlockByNumber(context.Background(), nil) - require.NoError(t, err) - // price can be increased by 12.5 % with every block when they are more than 50% full // 3x time multiplier is a safe choice to have - test.maxGas = new(big.Int).Mul(headBlock.BaseFee(), big.NewInt(3)) + if test.gasFeeCap == nil { + headBlock, err := test.Client.BlockByNumber(context.Background(), nil) + require.NoError(t, err) + baseFee := eth.UnitFromBigInt(headBlock.BaseFee(), eth.WEI) + t.Logf("Calculating gasFeeCap as 3x the baseFee of %s", baseFee.GWEI()) + gasFeeCap := baseFee.Mul(eth.UnitFromInt(3, eth.WEI)) + test.gasFeeCap = &gasFeeCap + } + + test.SetETHPrice("1000.00") return test } @@ -805,13 +929,25 @@ func (test *PipelineTest) InitializePayoutGroups(payouts []*pipelinedb.Payout) { } } -func (test *PipelineTest) SetStorjPrice(s string) { - test.Quoter.SetQuote(coinmarketcap.STORJ, &coinmarketcap.Quote{ +func (test *PipelineTest) SetETHPrice(price string) { + test.SetPrice(coinmarketcap.ETH, price) +} + +func (test *PipelineTest) SetStorjPrice(price string) { + test.SetPrice(coinmarketcap.STORJ, price) +} + +func (test *PipelineTest) SetPrice(symbol coinmarketcap.Symbol, price string) { + test.Quoter.SetQuote(symbol, &coinmarketcap.Quote{ LastUpdated: time.Now(), - Price: decimal.RequireFromString(s), + Price: decimal.RequireFromString(price), }) } +func (test *PipelineTest) AfterSleep(fn func()) { + test.afterSleep = append(test.afterSleep, fn) +} + func (test *PipelineTest) initNetwork() { // Create a network, giving the owner and spender a little bit of cheese // to get things going. @@ -856,22 +992,28 @@ func (test *PipelineTest) newPipeline(stepInCh chan chan []*pipelinedb.NonceGrou spenderKey = test.spender.Key } payer, err := eth.NewPayer(context.Background(), - test.Client, + overridePricer{Client: test.Client, gasFeeCap: test.gasFeeCap, gasTipCap: test.gasTipCap}, test.ContractAddress, owner.Address, spenderKey, - big.NewInt(1337), - test.gasTipCap, - test.maxGas) + testChainID, + eth.PayerOptions{ + GasFeeCapOverride: big.NewInt(70_000_000_000), + }, + ) test.R.NoError(err) + pipeline, err := New(payer, Config{ - Log: zaptest.NewLogger(test), - Owner: owner.Address, - Quoter: test.Quoter, - DB: test.DB, - Limit: test.limit, - stepInCh: stepInCh, - pollInterval: pollInterval, + Log: zaptest.NewLogger(test), + Owner: owner.Address, + Quoter: test.Quoter, + ThresholdDivisor: test.thresholdDivisor, + MaxFeeTolerationUSD: test.maxFeeTolerationUSD, + DB: test.DB, + Limit: test.limit, + stepInCh: stepInCh, + pollInterval: pollInterval, + sleep: test.sleep, }) test.R.NoError(err) return pipeline @@ -881,7 +1023,7 @@ func (test *PipelineTest) ProcessPayouts(step func(int, []*pipelinedb.NonceGroup stepInCh := make(chan chan []*pipelinedb.NonceGroup) pipeline := test.newPipeline(stepInCh, time.Minute) - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() err := pipeline.initPayout(ctx) @@ -896,6 +1038,7 @@ func (test *PipelineTest) ProcessPayouts(step func(int, []*pipelinedb.NonceGroup } done, err := pipeline.payoutStep(ctx) + test.Logf("RESULT: step=%d done=%t err=%v", i, done, err) var failed bool failed = failed || !test.A.Equal(expectedDone, done, "step %d had an unexpected done state", i) if expectedErr != nil { @@ -949,6 +1092,10 @@ func (test *PipelineTest) FetchPayoutGroupFinalTxHash(payoutGroupID int64) *comm return test.FetchPayoutGroup(payoutGroupID).FinalTxHash } +func (test *PipelineTest) FetchPayoutGroupStatus(payoutGroupID int64) pipelinedb.PayoutGroupStatus { + return test.FetchPayoutGroup(payoutGroupID).Status +} + func (test *PipelineTest) ValidatePipelineSlot(nonceGroup *pipelinedb.NonceGroup, nonce uint64, payoutGroupID int64, txStates ...pipelinedb.TxState) { test.R.Equal(nonce, nonceGroup.Nonce, "unexpected nonce") test.R.Len(nonceGroup.Txs, len(txStates), "unexpected number of transactions") @@ -1020,3 +1167,30 @@ func (test *PipelineTest) pendingTransactionCount() uint { test.R.NoError(err) return pending } + +func (test *PipelineTest) sleep(context.Context, time.Duration) error { + for _, afterSleep := range test.afterSleep { + afterSleep() + } + return nil +} + +type overridePricer struct { + eth.Client + gasFeeCap *eth.Unit + gasTipCap *eth.Unit +} + +func (p overridePricer) SuggestGasPrice(ctx context.Context) (*big.Int, error) { + if p.gasFeeCap != nil { + return p.gasFeeCap.WEIInt(), nil + } + return p.Client.SuggestGasPrice(ctx) +} + +func (p overridePricer) SuggestGasTipCap(ctx context.Context) (*big.Int, error) { + if p.gasTipCap != nil { + return p.gasTipCap.WEIInt(), nil + } + return p.Client.SuggestGasTipCap(ctx) +} diff --git a/pkg/pipeline/pipeline_test.go b/pkg/pipeline/pipeline_test.go index 1b5ccaf..09421a3 100644 --- a/pkg/pipeline/pipeline_test.go +++ b/pkg/pipeline/pipeline_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "github.com/zeebo/errs" "go.uber.org/zap" + "go.uber.org/zap/zaptest" "storj.io/crypto-batch-payment/pkg/coinmarketcap" "storj.io/crypto-batch-payment/pkg/payer" @@ -157,14 +158,11 @@ func createTestDB(ctx context.Context, t *testing.T, payouts []*pipelinedb.Payou } func createTestPipeline(ctx context.Context, t *testing.T, db *pipelinedb.DB) (*Pipeline, *TestPayer) { - logger, err := zap.NewDevelopment() - require.NoError(t, err) - cfg := Config{ DB: db, - Log: logger, + Log: zaptest.NewLogger(t), Quoter: &StaticQuoter{ - value: decimal.New(2, 0), + value: decimal.NewFromInt(2), }, } @@ -197,33 +195,51 @@ func (t *TestPayer) String() string { return "test" } +func (t *TestPayer) ChainID() int { + return testChainID +} + +func (t *TestPayer) Decimals() int32 { + return 8 +} + func (t *TestPayer) NextNonce(ctx context.Context) (uint64, error) { ret := t.nextNonce t.nextNonce++ return ret, nil } -func (t *TestPayer) CheckPreconditions(ctx context.Context) ([]string, error) { - return nil, nil +func (t *TestPayer) GetETHBalance(ctx context.Context) (*big.Int, error) { + balance, _ := new(big.Int).SetString("10_000_000_000_000_000_000", 0) // 10 ETH + return balance, nil } func (t *TestPayer) GetTokenBalance(ctx context.Context) (*big.Int, error) { return big.NewInt(10_000_00000000), nil } -func (t *TestPayer) GetTokenDecimals(ctx context.Context) (int32, error) { - return 8, nil +func (t *TestPayer) GetGasInfo(ctx context.Context) (payer.GasInfo, error) { + return payer.GasInfo{ + GasLimit: 50000, + GasFeeCap: big.NewInt(1_000_000_000), // 1.000 gwei + GasTipCap: big.NewInt(1_000_000), // 0.001 gwei + }, nil } -func (t *TestPayer) CreateRawTransaction(ctx context.Context, log *zap.Logger, payouts []*pipelinedb.Payout, nonce uint64, storjPrice decimal.Decimal) (tx payer.Transaction, from common.Address, err error) { +func (t *TestPayer) CreateRawTransaction(ctx context.Context, log *zap.Logger, params payer.TransactionParams) (_ payer.Transaction, _ common.Address, err error) { + gasInfo, err := t.GetGasInfo(ctx) + if err != nil { + return payer.Transaction{}, common.Address{}, err + } hash := make([]byte, 32) _, err = rand.Read(hash) return payer.Transaction{ - Hash: common.BytesToHash(hash).String(), - Nonce: nonce, - Raw: make(map[string]string), + Hash: common.BytesToHash(hash).String(), + Nonce: params.Nonce, + EstimatedGasLimit: gasInfo.GasLimit, + EstimatedGasFeeCap: gasInfo.GasFeeCap, + Raw: make(map[string]string), }, common.HexToAddress("0x94F31A2f6522dbf0594bf9c37F124fB6EAC4d9cd"), err - } func (t *TestPayer) SendTransaction(ctx context.Context, log *zap.Logger, tx payer.Transaction) error { @@ -249,6 +265,14 @@ func (s StaticQuoter) GetQuote(ctx context.Context, symbol coinmarketcap.Symbol) }, nil } +type StaticFeeEstimator struct { + value decimal.Decimal +} + +func (s StaticFeeEstimator) GetEstimatedGasFee(ctx context.Context) (decimal.Decimal, error) { + return s.value, nil +} + func statusResult(status pipelinedb.TxState, hash string) (pipelinedb.TxState, []*pipelinedb.TxStatus, error) { return status, []*pipelinedb.TxStatus{ { diff --git a/pkg/pipelinedb/db.go b/pkg/pipelinedb/db.go index 7bfe03c..c7da539 100644 --- a/pkg/pipelinedb/db.go +++ b/pkg/pipelinedb/db.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "math/big" "os" "path/filepath" @@ -20,7 +21,7 @@ import ( ) const ( - dbVersion = 2 + dbVersion = 3 ) type DB struct { @@ -143,6 +144,26 @@ func (db *DB) Close() error { return db.db.Close() } +func (db *DB) SetBonusMultiplier(ctx context.Context, bonusMultiplier decimal.Decimal) error { + return db.db.UpdateNoReturn_Metadata_By_Pk(ctx, payoutdb.Metadata_Pk(1), payoutdb.Metadata_Update_Fields{ + BonusMultiplier: payoutdb.Metadata_BonusMultiplier(bonusMultiplier.String()), + }) +} + +func (db *DB) GetBonusMultiplier(ctx context.Context) (decimal.Decimal, error) { + metadata, err := db.db.Find_Metadata_By_Pk(ctx, payoutdb.Metadata_Pk(1)) + switch { + case err != nil: + return decimal.Decimal{}, err + case metadata == nil: + return decimal.Decimal{}, errors.New("no metadata row") + case metadata.BonusMultiplier == nil: + return decimal.Decimal{}, nil + default: + return decimal.NewFromString(*metadata.BonusMultiplier) + } +} + func (db *DB) RecordStart(ctx context.Context, spender common.Address, owner *common.Address) error { var update payoutdb.Metadata_Update_Fields switch { @@ -175,6 +196,33 @@ func (db *DB) RecordStart(ctx context.Context, spender common.Address, owner *co return nil } +func (db *DB) CreatePayoutGroups(ctx context.Context, payoutGroups [][]*Payout) error { + return db.db.WithTx(ctx, func(tx *payoutdb.Tx) error { + for i, payouts := range payoutGroups { + payoutGroupID := int64(i + 1) + if err := tx.CreateNoReturn_PayoutGroup(ctx, + payoutdb.PayoutGroup_Id(payoutGroupID), + payoutdb.PayoutGroup_Create_Fields{}, + ); err != nil { + return err + } + for _, payout := range payouts { + payout.PayoutGroupID = payoutGroupID + if err := tx.CreateNoReturn_Payout(ctx, + payoutdb.Payout_CsvLine(payout.CSVLine), + payoutdb.Payout_Payee(payout.Payee.String()), + payoutdb.Payout_Usd(payout.USD.String()), + payoutdb.Payout_PayoutGroupId(payoutGroupID), + payoutdb.Payout_Mandatory(payout.Mandatory), + ); err != nil { + return err + } + } + } + return nil + }) +} + func (db *DB) CreatePayoutGroup(ctx context.Context, payoutGroupID int64, payouts []*Payout) error { return db.db.WithTx(ctx, func(tx *payoutdb.Tx) error { if err := tx.CreateNoReturn_PayoutGroup(ctx, @@ -190,6 +238,7 @@ func (db *DB) CreatePayoutGroup(ctx context.Context, payoutGroupID int64, payout payoutdb.Payout_Payee(payout.Payee.String()), payoutdb.Payout_Usd(payout.USD.String()), payoutdb.Payout_PayoutGroupId(payoutGroupID), + payoutdb.Payout_Mandatory(payout.Mandatory), ); err != nil { return err } @@ -292,6 +341,31 @@ func (db *DB) FetchPayoutGroupTransactions(ctx context.Context, payoutGroupID in return TransactionsFromRows(rows) } +// FetchPayoutGroupStatus returns the status of the given payout group. +func (db *DB) FetchPayoutGroupStatus(ctx context.Context, payoutGroupID int64) (PayoutGroupStatus, error) { + payoutGroup, err := db.db.Find_PayoutGroup_By_Id(ctx, payoutdb.PayoutGroup_Id(payoutGroupID)) + switch { + case err != nil: + return "", errs.Wrap(err) + case payoutGroup == nil: + return "", errs.New("no such payout group %d", payoutGroupID) + case payoutGroup.Status == nil: + return "", nil + default: + return PayoutGroupStatus(*payoutGroup.Status), nil + } +} + +// SetPayoutGroupStatus sets the payout group status. +func (db *DB) SetPayoutGroupStatus(ctx context.Context, payoutGroupID int64, status PayoutGroupStatus) error { + return errs.Wrap(db.db.UpdateNoReturn_PayoutGroup_By_Id(ctx, + payoutdb.PayoutGroup_Id(payoutGroupID), + payoutdb.PayoutGroup_Update_Fields{ + Status: payoutdb.PayoutGroup_Status(string(status)), + }, + )) +} + // FinalizeNonceGroup finalizes the transaction state for transactions in a // nonce group. It also sets the final tx hash on the payout group for the // confirmed transaction. @@ -306,6 +380,7 @@ func (db *DB) FinalizeNonceGroup(ctx context.Context, nonceGroup *NonceGroup, st payoutdb.PayoutGroup_Id(nonceGroup.PayoutGroupID), payoutdb.PayoutGroup_Update_Fields{ FinalTxHash: payoutdb.PayoutGroup_FinalTxHash(status.Hash), + Status: payoutdb.PayoutGroup_Status(string(PayoutGroupComplete)), }) if err != nil { return errs.Wrap(err) @@ -385,25 +460,31 @@ func (db *DB) FetchPayoutProgress(ctx context.Context) (int64, int64, error) { } type DBStats struct { - Spender *common.Address - Owner *common.Address - Payees int64 - TotalPayouts int64 - TotalUSD decimal.Decimal - PendingPayouts int64 - PendingUSD decimal.Decimal - TotalPayoutGroups int64 - PendingPayoutGroups int64 - TotalTransactions int64 - PendingTransactions int64 - FailedTransactions int64 - ConfirmedTransactions int64 - DroppedTransactions int64 -} - -func (db *DB) Stats(ctx context.Context) (_ *DBStats, err error) { + Spender *common.Address + Owner *common.Address + Payees int64 + TotalPayouts int64 + TotalUSD decimal.Decimal + PendingPayouts int64 + PendingUSD decimal.Decimal + PendingPayoutsBelowThreshold int64 + PendingPayoutsBelowThresholdUSD decimal.Decimal + TotalPayoutGroups int64 + PendingPayoutGroups int64 + SkippedPayoutGroups int64 + TotalTransactions int64 + PendingTransactions int64 + FailedTransactions int64 + ConfirmedTransactions int64 + DroppedTransactions int64 +} + +func (db *DB) Stats(ctx context.Context, payoutThresholdUSD decimal.Decimal) (_ *DBStats, err error) { stats := new(DBStats) + //////////////////////////////////////////////// + // Tally total payouts + //////////////////////////////////////////////// payoutRows, err := db.db.All_Payout(ctx) if err != nil { return nil, errs.Wrap(err) @@ -421,7 +502,10 @@ func (db *DB) Stats(ctx context.Context) (_ *DBStats, err error) { } stats.Payees = int64(len(payees)) - payoutRows, err = db.db.All_Payout_By_PayoutGroup_FinalTxHash_Is_Null(ctx) + //////////////////////////////////////////////// + // Tally pending payouts + //////////////////////////////////////////////// + payoutRows, err = db.db.All_Payout_By_PayoutGroup_Status(ctx, payoutdb.PayoutGroup_Status_Null()) if err != nil { return nil, errs.Wrap(err) } @@ -433,6 +517,10 @@ func (db *DB) Stats(ctx context.Context) (_ *DBStats, err error) { stats.PendingPayouts = int64(len(payouts)) for _, payout := range payouts { stats.PendingUSD = stats.PendingUSD.Add(payout.USD) + if payoutThresholdUSD.IsPositive() && payout.USD.Cmp(payoutThresholdUSD) < 0 { + stats.PendingPayoutsBelowThreshold++ + stats.PendingPayoutsBelowThresholdUSD = stats.PendingPayoutsBelowThresholdUSD.Add(payout.USD) + } } stats.TotalPayoutGroups, err = db.db.Count_PayoutGroup(ctx) @@ -440,7 +528,12 @@ func (db *DB) Stats(ctx context.Context) (_ *DBStats, err error) { return nil, errs.Wrap(err) } - stats.PendingPayoutGroups, err = db.db.Count_PayoutGroup_By_FinalTxHash_Is_Null(ctx) + stats.PendingPayoutGroups, err = db.db.Count_PayoutGroup_By_Status(ctx, payoutdb.PayoutGroup_Status_Null()) + if err != nil { + return nil, errs.Wrap(err) + } + + stats.SkippedPayoutGroups, err = db.db.Count_PayoutGroup_By_Status(ctx, payoutdb.PayoutGroup_Status(string(PayoutGroupSkipped))) if err != nil { return nil, errs.Wrap(err) } @@ -493,6 +586,7 @@ type Payout struct { Payee common.Address USD decimal.Decimal PayoutGroupID int64 + Mandatory bool } func PayoutsFromRows(rows []*payoutdb.Payout) ([]*Payout, error) { @@ -521,12 +615,14 @@ func PayoutFromRow(row *payoutdb.Payout) (*Payout, error) { Payee: payee, USD: usd, PayoutGroupID: row.PayoutGroupId, + Mandatory: row.Mandatory, }, nil } type PayoutGroup struct { ID int64 FinalTxHash *common.Hash + Status PayoutGroupStatus } func PayoutGroupsFromRows(rows []*payoutdb.PayoutGroup) ([]*PayoutGroup, error) { @@ -542,6 +638,10 @@ func PayoutGroupsFromRows(rows []*payoutdb.PayoutGroup) ([]*PayoutGroup, error) } func PayoutGroupFromRow(row *payoutdb.PayoutGroup) (*PayoutGroup, error) { + if row == nil { + return nil, nil + } + var finalTxHash *common.Hash if row.FinalTxHash != nil { hash, err := batchpayment.HashFromString(*row.FinalTxHash) @@ -551,9 +651,15 @@ func PayoutGroupFromRow(row *payoutdb.PayoutGroup) (*PayoutGroup, error) { finalTxHash = &hash } + var status PayoutGroupStatus + if row.Status != nil { + status = PayoutGroupStatus(*row.Status) + } + return &PayoutGroup{ ID: row.Id, FinalTxHash: finalTxHash, + Status: status, }, nil } @@ -720,6 +826,10 @@ func migrateDB(ctx context.Context, db *payoutdb.DB, version int) (err error) { if err := migrateV2(ctx, tx); err != nil { return err } + case 3: + if err := migrateV3(ctx, tx); err != nil { + return err + } default: return errs.New("no migration to version %d available", to) } @@ -741,7 +851,7 @@ func migrateDB(ctx context.Context, db *payoutdb.DB, version int) (err error) { func migrateV2(ctx context.Context, tx *sql.Tx) error { // version 2 renamed the "payer" column in both metadata and // transaction tables to "owner". - stmts := []string{ + return execMany(ctx, tx, // Rename owner in metadata table `CREATE TABLE __metadata_new( pk INTEGER NOT NULL, @@ -780,8 +890,19 @@ func migrateV2(ctx context.Context, tx *sql.Tx) error { SELECT pk, created_at, updated_at, hash, payer, spender, nonce, estimated_gas_price, storj_price, storj_tokens, payout_group_id, raw, state, receipt FROM tx;`, `DROP TABLE tx;`, `ALTER TABLE __tx_new RENAME TO tx;`, - } + ) +} + +func migrateV3(ctx context.Context, tx *sql.Tx) error { + // version 3 adds the "migration" column to the payout table + return execMany(ctx, tx, + `ALTER TABLE metadata ADD COLUMN bonus_multiplier TEXT;`, + `ALTER TABLE payout ADD COLUMN mandatory INTEGER NOT NULL DEFAULT 0;`, + `ALTER TABLE payout_group ADD COLUMN status TEXT;`, + ) +} +func execMany(ctx context.Context, tx *sql.Tx, stmts ...string) error { for _, stmt := range stmts { if _, err := tx.ExecContext(ctx, stmt); err != nil { return errs.Wrap(err) diff --git a/pkg/pipelinedb/migration_test.go b/pkg/pipelinedb/migration_test.go index af2ebf9..3f4a9e4 100644 --- a/pkg/pipelinedb/migration_test.go +++ b/pkg/pipelinedb/migration_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "testing" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -54,7 +55,7 @@ func testMigration(t *testing.T, version int) { // Try to gather stats. This isn't an exhaustive check that the // migration succeeded but reads from most tables. - _, err = db.Stats(ctx) + _, err = db.Stats(ctx, decimal.NewFromInt(0)) assert.NoError(t, err) // If not readOnly, try to attempt a write to make sure the database @@ -65,11 +66,11 @@ func testMigration(t *testing.T, version int) { require.NoError(t, db.Close()) - // Assert that the version has been updated + // Assert that the version has been updated to the latest doRaw(t, dbPath, func(t *testing.T, rawDB *sql.DB) { var gotVersion int require.NoError(t, rawDB.QueryRow("SELECT version FROM metadata").Scan(&gotVersion)) - assert.Equal(t, version+1, gotVersion) + assert.Equal(t, dbVersion, gotVersion) }) } diff --git a/pkg/pipelinedb/payoutgroup.go b/pkg/pipelinedb/payoutgroup.go new file mode 100644 index 0000000..f02f67d --- /dev/null +++ b/pkg/pipelinedb/payoutgroup.go @@ -0,0 +1,8 @@ +package pipelinedb + +type PayoutGroupStatus string + +const ( + PayoutGroupSkipped = PayoutGroupStatus("skipped") + PayoutGroupComplete = PayoutGroupStatus("complete") +) diff --git a/pkg/pipelinedb/testdata/v2.sql b/pkg/pipelinedb/testdata/v2.sql new file mode 100644 index 0000000..f202e10 --- /dev/null +++ b/pkg/pipelinedb/testdata/v2.sql @@ -0,0 +1,52 @@ +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE metadata ( + pk INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + version INTEGER NOT NULL, + attempts INTEGER NOT NULL, + spender TEXT, + owner TEXT, + PRIMARY KEY ( pk ) +); +INSERT INTO metadata VALUES(1,'2019-09-14 15:03:11.593+00:00','2019-09-14 15:03:11.593+00:00',2,0,NULL,NULL); +CREATE TABLE payout_group ( + pk INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + id INTEGER NOT NULL, + final_tx_hash TEXT, + PRIMARY KEY ( pk ), + UNIQUE ( id ) +); +INSERT INTO payout_group VALUES(1,'2019-09-14 15:03:11.608+00:00','2019-09-14 15:03:11.608+00:00',1,NULL); +CREATE TABLE payout ( + pk INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL, + csv_line INTEGER NOT NULL, + payee TEXT NOT NULL, + usd TEXT NOT NULL, + payout_group_id INTEGER NOT NULL REFERENCES payout_group( id ), + PRIMARY KEY ( pk ) +); +INSERT INTO payout VALUES(1,'2019-09-14 15:03:11.608+00:00',2,'0xC043c8e32697298CaE99AD69027aAbd84610D244','0.00005',1); +CREATE TABLE tx ( + pk INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + hash TEXT NOT NULL, + owner TEXT NOT NULL, + spender TEXT NOT NULL, + nonce INTEGER NOT NULL, + estimated_gas_price TEXT NOT NULL, + storj_price TEXT NOT NULL, + storj_tokens TEXT NOT NULL, + payout_group_id INTEGER NOT NULL REFERENCES payout_group( id ), + raw TEXT NOT NULL, + state TEXT NOT NULL, + receipt TEXT, + PRIMARY KEY ( pk ), + UNIQUE ( hash ) +); +COMMIT; diff --git a/pkg/zksyncera/auditor.go b/pkg/zksyncera/auditor.go index b9a623e..c229021 100644 --- a/pkg/zksyncera/auditor.go +++ b/pkg/zksyncera/auditor.go @@ -62,9 +62,9 @@ func (a *Auditor) CheckConfirmedTransactionState(ctx context.Context, hash strin func stateFromStatus(status string) (pipelinedb.TxState, error) { switch strings.ToLower(status) { - case "pending", "included": + case "pending": return pipelinedb.TxPending, nil - case "verified": + case "included", "verified": return pipelinedb.TxConfirmed, nil case "failed": return pipelinedb.TxFailed, nil diff --git a/pkg/zksyncera/payer.go b/pkg/zksyncera/payer.go index 654e03b..a8d5524 100644 --- a/pkg/zksyncera/payer.go +++ b/pkg/zksyncera/payer.go @@ -3,6 +3,7 @@ package zksyncera import ( "context" "crypto/ecdsa" + "errors" "fmt" "math/big" "strings" @@ -26,11 +27,15 @@ import ( "storj.io/crypto-batch-payment/pkg/contract" "storj.io/crypto-batch-payment/pkg/payer" "storj.io/crypto-batch-payment/pkg/pipelinedb" - "storj.io/crypto-batch-payment/pkg/storjtoken" +) + +var ( + _ payer.Payer = &Payer{} ) type Payer struct { wallet *accounts.Wallet + chainID int zk clients.Client signer *accounts.BaseSigner contractAddress common.Address @@ -46,8 +51,7 @@ func NewPayer( key *ecdsa.PrivateKey, chainID int, paymasterAddress *common.Address, - paymasterPayload []byte, - maxFee *big.Int) (*Payer, error) { + paymasterPayload []byte) (*Payer, error) { ethSigner, err := accounts.NewBaseSignerFromRawPrivateKey(key.D.Bytes(), int64(chainID)) if err != nil { @@ -72,6 +76,7 @@ func NewPayer( p := &Payer{ wallet: wallet, + chainID: chainID, zk: zkClients, signer: ethSigner, contractAddress: contractAddress, @@ -79,15 +84,22 @@ func NewPayer( paymasterAddress: paymasterAddress, paymasterPayload: paymasterPayload, } - p.decimals, err = p.GetTokenDecimals(context.Background()) + p.decimals, err = p.getTokenDecimals(context.Background()) return p, errs.Wrap(err) - } func (p *Payer) String() string { return payer.ZkSyncEra.String() } +func (p *Payer) ChainID() int { + return p.chainID +} + +func (p *Payer) Decimals() int32 { + return p.decimals +} + func (p *Payer) NextNonce(ctx context.Context) (uint64, error) { nonce, err := p.wallet.Nonce(ctx, nil) if err != nil { @@ -96,43 +108,43 @@ func (p *Payer) NextNonce(ctx context.Context) (uint64, error) { return nonce, nil } -func (p *Payer) CheckPreconditions(ctx context.Context) ([]string, error) { - return nil, nil +func (p *Payer) GetETHBalance(ctx context.Context) (*big.Int, error) { + return p.wallet.Balance(ctx, utils.EthAddress, nil) } func (p *Payer) GetTokenBalance(ctx context.Context) (*big.Int, error) { return p.wallet.Balance(ctx, p.contractAddress, nil) } -func (p *Payer) GetTokenDecimals(ctx context.Context) (int32, error) { - tokenContract, err := contract.NewToken(p.contractAddress, p.zk) +func (p *Payer) GetGasInfo(ctx context.Context) (payer.GasInfo, error) { + // Use a fixed, unlikely, payee address and one token for the estimate. + payee := common.HexToAddress("0xdeadbeef") + tokens := decimal.NewFromInt(1).Shift(p.decimals) + + data, err := p.erc20abi.Pack("transfer", payee, tokens.BigInt()) if err != nil { - return 0, fmt.Errorf("failed to load ERC20: %w", err) + return payer.GasInfo{}, errs.Wrap(err) } - decimals, err := tokenContract.Decimals(&bind.CallOpts{}) + + feeEstimate, err := p.getFeeEstimate(ctx, data) if err != nil { - return 0, fmt.Errorf("failed to load ERC20: %w", err) + return payer.GasInfo{}, errs.Wrap(err) } - return int32(decimals.Int64()), nil + return payer.GasInfo{ + GasFeeCap: feeEstimate.MaxFeePerGas.ToInt(), + GasTipCap: feeEstimate.MaxPriorityFeePerGas.ToInt(), + GasLimit: feeEstimate.GasLimit.ToInt().Uint64(), + }, nil } -func (p *Payer) CreateRawTransaction(ctx context.Context, log *zap.Logger, payouts []*pipelinedb.Payout, nonce uint64, storjPrice decimal.Decimal) (tx payer.Transaction, from common.Address, err error) { - from = p.signer.Address() - - if len(payouts) > 1 { - return payer.Transaction{}, common.Address{}, errs.New("multitransfer is not supported yet") - } - payout := payouts[0] - - tokenAmount := storjtoken.FromUSD(payout.USD, storjPrice, p.decimals) - - packedData, err := p.erc20abi.Pack("transfer", payout.Payee, tokenAmount) +func (p *Payer) CreateRawTransaction(ctx context.Context, log *zap.Logger, params payer.TransactionParams) (_ payer.Transaction, _ common.Address, err error) { + data, err := p.erc20abi.Pack("transfer", params.Payee, params.Tokens) if err != nil { return payer.Transaction{}, common.Address{}, errs.Wrap(err) } - gasPrice, err := p.zk.SuggestGasPrice(ctx) + feeEstimate, err := p.getFeeEstimate(ctx, data) if err != nil { return payer.Transaction{}, common.Address{}, errs.Wrap(err) } @@ -142,59 +154,41 @@ func (p *Payer) CreateRawTransaction(ctx context.Context, log *zap.Logger, payou return payer.Transaction{}, common.Address{}, errs.Wrap(err) } - callMsg := zktypes.CallMsg{ - CallMsg: ethereum.CallMsg{ - From: from, - To: &p.contractAddress, - Gas: 0, // estimated below - GasTipCap: big.NewInt(0), // TODO: Estimate correct one - GasFeeCap: gasPrice, - Value: nil, - Data: packedData, - }, + from := p.signer.Address() + tx := &zktypes.Transaction712{ + Nonce: big.NewInt(int64(params.Nonce)), + GasTipCap: feeEstimate.MaxPriorityFeePerGas.ToInt(), + GasFeeCap: feeEstimate.MaxFeePerGas.ToInt(), + Gas: feeEstimate.GasLimit.ToInt(), + To: &p.contractAddress, + Data: data, + ChainID: chainID, + From: &from, Meta: &zktypes.Eip712Meta{ - GasPerPubdata: utils.NewBig(utils.DefaultGasPerPubdataLimit.Int64()), + GasPerPubdata: feeEstimate.GasPerPubdataLimit, }, } if p.paymasterAddress != nil { - callMsg.Meta.PaymasterParams = &zktypes.PaymasterParams{ + tx.Meta.PaymasterParams = &zktypes.PaymasterParams{ Paymaster: *p.paymasterAddress, PaymasterInput: p.paymasterPayload, } } - gas, err := p.zk.EstimateGasL2(ctx, callMsg) - if err != nil { - return payer.Transaction{}, common.Address{}, errs.Wrap(err) - } - - data := &zktypes.Transaction712{ - Nonce: big.NewInt(int64(nonce)), - GasTipCap: callMsg.GasTipCap, - GasFeeCap: callMsg.GasFeeCap, - Gas: new(big.Int).SetUint64(gas), - To: callMsg.To, - Value: callMsg.Value, - Data: callMsg.Data, - ChainID: chainID, - From: &callMsg.From, - Meta: callMsg.Meta, - } - domain := p.signer.Domain() - message, err := data.EIP712Message() + message, err := tx.EIP712Message() if err != nil { return payer.Transaction{}, common.Address{}, errs.Wrap(err) } typedData := apitypes.TypedData{ Types: apitypes.Types{ - data.EIP712Type(): data.EIP712Types(), + tx.EIP712Type(): tx.EIP712Types(), domain.EIP712Type(): domain.EIP712Types(), }, - PrimaryType: data.EIP712Type(), + PrimaryType: tx.EIP712Type(), Domain: domain.EIP712Domain(), Message: message, } @@ -204,12 +198,12 @@ func (p *Payer) CreateRawTransaction(ctx context.Context, log *zap.Logger, payou return payer.Transaction{}, common.Address{}, errs.Wrap(err) } - signature, err := p.signer.SignTypedData(domain, data) + signature, err := p.signer.SignTypedData(domain, tx) if err != nil { return payer.Transaction{}, common.Address{}, errs.Wrap(err) } - rawTx, err := data.RLPValues(signature) + rawTx, err := tx.RLPValues(signature) if err != nil { return payer.Transaction{}, common.Address{}, errs.Wrap(err) } @@ -220,9 +214,11 @@ func (p *Payer) CreateRawTransaction(ctx context.Context, log *zap.Logger, payou )) return payer.Transaction{ - Hash: hash.String(), - Nonce: nonce, - Raw: rawTx, + Hash: hash.String(), + Nonce: params.Nonce, + EstimatedGasLimit: feeEstimate.GasLimit.ToInt().Uint64(), + EstimatedGasFeeCap: feeEstimate.MaxFeePerGas.ToInt(), + Raw: rawTx, }, from, nil } @@ -244,8 +240,11 @@ func (p *Payer) CheckNonceGroup(ctx context.Context, log *zap.Logger, nonceGroup txHash := common.HexToHash(nonceGroup.Txs[0].Hash) zkReceipt, err := p.zk.TransactionReceipt(ctx, txHash) - if err != nil { - return pipelinedb.TxDropped, []*pipelinedb.TxStatus{}, errs.Wrap(err) + switch { + case errors.Is(err, ethereum.NotFound): + return pipelinedb.TxDropped, nil, nil + case err != nil: + return pipelinedb.TxDropped, nil, errs.Wrap(err) } status := pipelinedb.TxConfirmed @@ -279,6 +278,26 @@ func (p *Payer) PrintEstimate(ctx context.Context, remaining int64) error { return nil } -var ( - _ payer.Payer = &Payer{} -) +func (p *Payer) getTokenDecimals(ctx context.Context) (int32, error) { + tokenContract, err := contract.NewToken(p.contractAddress, p.zk) + if err != nil { + return 0, fmt.Errorf("failed to load ERC20: %w", err) + } + decimals, err := tokenContract.Decimals(&bind.CallOpts{}) + if err != nil { + return 0, fmt.Errorf("failed to load ERC20: %w", err) + } + return int32(decimals.Int64()), nil +} + +func (p *Payer) getFeeEstimate(ctx context.Context, data []byte) (*zktypes.Fee, error) { + callMsg := zktypes.CallMsg{ + CallMsg: ethereum.CallMsg{ + From: p.signer.Address(), + To: &p.contractAddress, + Data: data, + }, + } + + return p.zk.EstimateFee(ctx, callMsg) +}