Skip to content
Open
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
/.tmp
.env
.DS_Store
node_modules
node_modules
.idea/
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,37 @@ mechanics:
# ...
- max_participants: 30000
apr_boost: 0.05

# Redirect rewards to different addresses. The left-hand side is the owner address,
# and the right-hand side is the reward recipient address. Do not specify deployer
# addresses on the left-hand side, only owner addresses.
reward_redirects:
owner_redirects:
"0x1234567890abcdef1234567890abcdef12345678": "0x1234567890abcdef1234567890abcdef12345678"

# Redirect rewards to different addresses by validator public key. The left-hand side is the validator
# public key, and the right-hand side is the reward recipient address.
validator_redirects:
"0x1234500012345000123450001234500012345000123450001234500012345000123450001234500012345000123450001234": "0x1234567890abcdef1234567890abcdef12345678"

# Alternatively, you can specify redirects using external CSV files instead, for example:
# owner_redirects_file: owner_redirects_2023_11.csv
# validator_redirects_file: validator_redirects_2023_11.csv
#
# Example for `owner_redirects_file`:
# from,to
# 0x1234567890abcdef1234567890abcdef12345678,0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef
# 0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef,0x1234567890abcdef1234567890abcdef12345678
#
# Example for `validator_redirects_file`:
# from,to
# 0x1234500012345000123450001234500012345000123450001234500012345000123450001234500012345000123450001234,0x1234567890abcdef1234567890abcdef12345678
# 0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef,0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef
#
# Notes:
# - Header is required.
# - File redirects are mutually exclusive with inline redirects: only one can be specified.
# - Files are copied to the `inputs` directory during `calc` for preservation and reproducibility.

rounds:
- period: 2023-07 # Designated period (year-month)
eth_apr: 0.047 # ETH Staking APR
Expand Down
183 changes: 154 additions & 29 deletions cmd/ssv-rewards/calc.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@ import (
"time"

"github.com/attestantio/go-eth2-client/spec/phase0"
"github.com/bloxapp/ssv-rewards/pkg/models"
"github.com/bloxapp/ssv-rewards/pkg/precise"
"github.com/bloxapp/ssv-rewards/pkg/rewards"
"github.com/bloxapp/ssv/networkconfig"
"github.com/gocarina/gocsv"
"github.com/volatiletech/sqlboiler/v4/boil"
"github.com/volatiletech/sqlboiler/v4/queries"
"go.uber.org/zap"
"golang.org/x/exp/maps"

"github.com/bloxapp/ssv-rewards/pkg/models"
"github.com/bloxapp/ssv-rewards/pkg/precise"
"github.com/bloxapp/ssv-rewards/pkg/rewards"
)

type CalcCmd struct {
Expand Down Expand Up @@ -102,6 +103,22 @@ func (c *CalcCmd) Run(
return fmt.Errorf("failed to write rewards.json: %w", err)
}

// Export redirects from files.
for _, mechanics := range c.plan.Mechanics {
if mechanics.OwnerRedirectsFile != "" {
filePath := filepath.Join(inputsDir, filepath.Base(mechanics.OwnerRedirectsFile))
if err := exportRedirectsToCSV(mechanics.OwnerRedirects, filePath); err != nil {
return fmt.Errorf("failed to export owner redirects for period %s: %w", mechanics.Since, err)
}
}
if mechanics.ValidatorRedirectsFile != "" {
filePath := filepath.Join(inputsDir, filepath.Base(mechanics.ValidatorRedirectsFile))
if err := exportRedirectsToCSV(mechanics.ValidatorRedirects, filePath); err != nil {
return fmt.Errorf("failed to export validator redirects for period %s: %w", mechanics.Since, err)
}
}
}

// Calculate rewards.
if err := c.run(ctx, logger, tmpDir); err != nil {
return fmt.Errorf("failed to calculate rewards: %w", err)
Expand Down Expand Up @@ -341,11 +358,12 @@ func (c *CalcCmd) run(ctx context.Context, logger *zap.Logger, dir string) error
}

type ValidatorParticipation struct {
OwnerAddress string
PublicKey string
ActiveDays int
Reward *precise.ETH `boil:"-"`
reward *big.Int `boil:"-"`
OwnerAddress string
RecipientAddress string
PublicKey string
ActiveDays int
Reward *precise.ETH `boil:"-"`
reward *big.Int `boil:"-"`
}

type ValidatorParticipationRound struct {
Expand All @@ -357,14 +375,30 @@ func (c *CalcCmd) validatorParticipations(
ctx context.Context,
period rewards.Period,
) ([]*ValidatorParticipation, error) {
var rewards []*ValidatorParticipation
return rewards, queries.Raw(
"SELECT * FROM active_days_by_validator($1, $2, $3, $4)",
var participant []*ValidatorParticipation

// Retrieve mechanics for the given period
mechanics, err := c.plan.Mechanics.At(period)
if err != nil {
return nil, fmt.Errorf("failed to get mechanics for period %s: %w", period, err)
}

// Determine feature support
gnosisSafeSupport := mechanics.Features.Enabled(rewards.FeatureGnosisSafe)
ownerRedirectsSupport := len(mechanics.OwnerRedirects) > 0
validatorRedirectsSupport := len(mechanics.ValidatorRedirects) > 0

return participant, queries.Raw(
"SELECT * FROM active_days_by_validator($1, $2, $3, $4, $5, $6, $7, $8)",
c.PerformanceProvider,
c.plan.Criteria.MinAttestationsPerDay,
c.plan.Criteria.MinDecidedsPerDay,
time.Time(period),
).Bind(ctx, c.db, &rewards)
nil, // to_period can be nil for single-period queries
gnosisSafeSupport,
ownerRedirectsSupport,
validatorRedirectsSupport,
).Bind(ctx, c.db, &participant)
}

type OwnerParticipation struct {
Expand Down Expand Up @@ -416,47 +450,56 @@ func (c *CalcCmd) recipientParticipations(
return nil, fmt.Errorf("failed to get mechanics: %w", err)
}
gnosisSafeSupport := mechanics.Features.Enabled(rewards.FeatureGnosisSafe)
rewardRedirectsSupport := len(mechanics.RewardRedirects) > 0

if rewardRedirectsSupport {
err := c.populateRewardRedirectsTable(ctx, mechanics.RewardRedirects)
ownerRedirectsSupport := len(mechanics.OwnerRedirects) > 0
if ownerRedirectsSupport {
err := c.populateOwnerRedirectsTable(ctx, mechanics.OwnerRedirects)
if err != nil {
return nil, fmt.Errorf("failed to populate reward redirects table: %w", err)
}
}

validatorRedirectsSupport := len(mechanics.ValidatorRedirects) > 0
if validatorRedirectsSupport {
err := c.populateValidatorRedirectsTable(ctx, mechanics.ValidatorRedirects)
if err != nil {
return nil, fmt.Errorf("failed to populate reward redirects table: %w", err)
}
}

var rewards []*RecipientParticipation
return rewards, queries.Raw(
"SELECT * FROM active_days_by_recipient($1, $2, $3, $4, $5, $6, $7)",
"SELECT * FROM active_days_by_recipient($1, $2, $3, $4, $5, $6, $7, $8)",
c.PerformanceProvider,
c.plan.Criteria.MinAttestationsPerDay,
c.plan.Criteria.MinDecidedsPerDay,
time.Time(period),
nil,
gnosisSafeSupport,
rewardRedirectsSupport,
ownerRedirectsSupport,
validatorRedirectsSupport,
).Bind(ctx, c.db, &rewards)
}

func (c *CalcCmd) populateRewardRedirectsTable(
func (c *CalcCmd) populateOwnerRedirectsTable(
ctx context.Context,
redirects rewards.Redirects,
redirects rewards.OwnerRedirects,
) error {
// Truncate the reward_redirects table.
// Truncate the owner_redirects table.
_, err := queries.Raw(
"TRUNCATE TABLE "+models.TableNames.RewardRedirects,
"TRUNCATE TABLE "+models.TableNames.OwnerRedirects,
).ExecContext(ctx, c.db)
if err != nil {
return fmt.Errorf("failed to truncate reward_redirects: %w", err)
return fmt.Errorf("failed to truncate owner_redirects: %w", err)
}

// Verify that the table is empty.
count, err := models.RewardRedirects().Count(ctx, c.db)
count, err := models.OwnerRedirects().Count(ctx, c.db)
if err != nil {
return fmt.Errorf("failed to count reward_redirects: %w", err)
return fmt.Errorf("failed to count owner_redirects: %w", err)
}
if count != 0 {
return fmt.Errorf("reward_redirects table was not truncated")
return fmt.Errorf("owner_redirects table was not truncated")
}

// Populate with given redirects.
Expand All @@ -466,7 +509,7 @@ func (c *CalcCmd) populateRewardRedirectsTable(
}
defer tx.Rollback()
for from, to := range redirects {
model := models.RewardRedirect{
model := models.OwnerRedirect{
FromAddress: from.String(),
ToAddress: to.String(),
}
Expand All @@ -479,12 +522,64 @@ func (c *CalcCmd) populateRewardRedirectsTable(
}

// Verify that the table is populated.
count, err = models.RewardRedirects().Count(ctx, c.db)
count, err = models.OwnerRedirects().Count(ctx, c.db)
if err != nil {
return fmt.Errorf("failed to count reward_redirects: %w", err)
return fmt.Errorf("failed to count owner_redirects: %w", err)
}
if int(count) != len(redirects) {
return fmt.Errorf("reward_redirects table was not populated")
return fmt.Errorf("owner_redirects table was not populated")
}

return nil
}

func (c *CalcCmd) populateValidatorRedirectsTable(
ctx context.Context,
redirects rewards.ValidatorRedirects,
) error {
// Truncate the validator_redirects table.
_, err := queries.Raw(
"TRUNCATE TABLE "+models.TableNames.ValidatorRedirects,
).ExecContext(ctx, c.db)
if err != nil {
return fmt.Errorf("failed to truncate validator_redirects: %w", err)
}

// Verify that the table is empty.
count, err := models.ValidatorRedirects().Count(ctx, c.db)
if err != nil {
return fmt.Errorf("failed to count validator_redirects: %w", err)
}
if count != 0 {
return fmt.Errorf("validator_redirects table was not truncated")
}

// Populate with given redirects.
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
for pubkey, to := range redirects {
model := models.ValidatorRedirect{
PublicKey: pubkey.String(),
ToAddress: to.String(),
}
if err := model.Insert(ctx, tx, boil.Infer()); err != nil {
return fmt.Errorf("failed to insert rewards_redirect: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}

// Verify that the table is populated.
count, err = models.ValidatorRedirects().Count(ctx, c.db)
if err != nil {
return fmt.Errorf("failed to count validator_redirects: %w", err)
}
if int(count) != len(redirects) {
return fmt.Errorf("validator_redirects table was not populated")
}

return nil
Expand Down Expand Up @@ -561,3 +656,33 @@ func exportCSV(data any, fileName string) error {
}
return nil
}

func exportRedirectsToCSV(redirects interface{}, fileName string) error {
type RedirectRow struct {
From string `csv:"from"`
To string `csv:"to"`
}

var rows []RedirectRow

switch r := redirects.(type) {
case rewards.OwnerRedirects:
for from, to := range r {
rows = append(rows, RedirectRow{
From: from.String(),
To: to.String(),
})
}
case rewards.ValidatorRedirects:
for from, to := range r {
rows = append(rows, RedirectRow{
From: from.String(),
To: to.String(),
})
}
default:
return fmt.Errorf("unsupported redirects type: %T", redirects)
}

return exportCSV(rows, fileName)
}
6 changes: 4 additions & 2 deletions pkg/models/boil_table_names.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading