From 5389c28dadada2f06f03ffae1a1eb5b5998f9811 Mon Sep 17 00:00:00 2001 From: Arkadiy Kukarkin Date: Fri, 13 Mar 2026 13:14:22 +0100 Subject: [PATCH 1/3] add costs package for upload cost estimation port cost calculation logic from synapse-sdk PR #632 and #666. new costs/ package with pure calculation functions (effective rate, additional lockup, deposit needed), FWSS contract getServicePrice() wrapper, USDFC sybil fee reader, and orchestrator service. integrated into synapse.Client via lazy Costs() getter. --- .gitignore | 1 + constants/sizes.go | 2 + costs/calculate.go | 133 +++++++++++++++++ costs/calculate_test.go | 319 ++++++++++++++++++++++++++++++++++++++++ costs/constants.go | 18 +++ costs/service.go | 317 +++++++++++++++++++++++++++++++++++++++ costs/types.go | 29 ++++ synapse.go | 38 +++++ warmstorage/fwss.go | 100 +++++++++++++ warmstorage/types.go | 9 ++ 10 files changed, 966 insertions(+) create mode 100644 costs/calculate.go create mode 100644 costs/calculate_test.go create mode 100644 costs/constants.go create mode 100644 costs/service.go create mode 100644 costs/types.go create mode 100644 warmstorage/fwss.go diff --git a/.gitignore b/.gitignore index 890cac1..7e658fe 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ go.work # OS files .DS_Store Thumbs.db +.claude/settings.local.json diff --git a/constants/sizes.go b/constants/sizes.go index 0793be0..5f732bc 100644 --- a/constants/sizes.go +++ b/constants/sizes.go @@ -9,4 +9,6 @@ const ( MaxUploadSize = GiB * 127 / 128 MinUploadSize = 127 + + BytesPerLeaf = 32 ) diff --git a/costs/calculate.go b/costs/calculate.go new file mode 100644 index 0000000..eb123b7 --- /dev/null +++ b/costs/calculate.go @@ -0,0 +1,133 @@ +package costs + +import ( + "math/big" + + "github.com/data-preservation-programs/go-synapse/constants" + "github.com/data-preservation-programs/go-synapse/warmstorage" +) + +var ( + bigOne = big.NewInt(1) + bigTiB = big.NewInt(constants.TiB) +) + +// CalculateEffectiveRate computes the storage rate for the given data size. +// ratePerEpoch uses integer division to match on-chain truncation. +// Both rates are floored to their corresponding minimums. +func CalculateEffectiveRate( + sizeBytes *big.Int, + pricePerTiBPerMonth *big.Int, + minMonthlyRate *big.Int, + epochsPerMonth int64, +) EffectiveRate { + ratePerMonth := new(big.Int).Mul(pricePerTiBPerMonth, sizeBytes) + ratePerMonth.Div(ratePerMonth, bigTiB) + if ratePerMonth.Cmp(minMonthlyRate) < 0 { + ratePerMonth.Set(minMonthlyRate) + } + + epm := big.NewInt(epochsPerMonth) + ratePerEpoch := new(big.Int).Mul(pricePerTiBPerMonth, sizeBytes) + ratePerEpoch.Div(ratePerEpoch, bigTiB) + ratePerEpoch.Div(ratePerEpoch, epm) + + minEpochRate := new(big.Int).Div(minMonthlyRate, epm) + if minEpochRate.Cmp(bigOne) < 0 { + minEpochRate.Set(bigOne) + } + if ratePerEpoch.Cmp(minEpochRate) < 0 { + ratePerEpoch.Set(minEpochRate) + } + + return EffectiveRate{ + RatePerEpoch: ratePerEpoch, + RatePerMonth: ratePerMonth, + } +} + +func CalculateAdditionalLockupRequired( + dataSizeBytes *big.Int, + currentDataSetSizeBytes *big.Int, + pricing *warmstorage.ServicePrice, + lockupPeriod int64, + usdfcSybilFee *big.Int, + isNewDataSet bool, + enableCDN bool, +) AdditionalLockup { + newTotalSize := new(big.Int).Add(currentDataSetSizeBytes, dataSizeBytes) + epm := pricing.EpochsPerMonth.Int64() + + newRate := CalculateEffectiveRate( + newTotalSize, + pricing.PricePerTiBPerMonthNoCDN, + pricing.MinimumPricePerMonth, + epm, + ) + + var rateDelta *big.Int + if isNewDataSet { + rateDelta = new(big.Int).Set(newRate.RatePerEpoch) + } else { + currentRate := CalculateEffectiveRate( + currentDataSetSizeBytes, + pricing.PricePerTiBPerMonthNoCDN, + pricing.MinimumPricePerMonth, + epm, + ) + rateDelta = new(big.Int).Sub(newRate.RatePerEpoch, currentRate.RatePerEpoch) + if rateDelta.Sign() < 0 { + rateDelta.SetInt64(0) + } + } + + totalLockup := new(big.Int).Mul(rateDelta, big.NewInt(lockupPeriod)) + + if isNewDataSet && enableCDN { + totalLockup.Add(totalLockup, CDNFixedLockup) + totalLockup.Add(totalLockup, CacheMissFixedLockup) + } + + if isNewDataSet && usdfcSybilFee != nil { + totalLockup.Add(totalLockup, usdfcSybilFee) + } + + return AdditionalLockup{ + RateDelta: rateDelta, + TotalLockup: totalLockup, + } +} + +// CalculateDepositNeeded computes the USDFC deposit required to cover lockup, +// runway, and buffer. Buffer is skipped when currentLockupRate is zero and +// isNewDataSet is true (deposit lands before any rail is created). +func CalculateDepositNeeded( + additionalLockup *big.Int, + rateDelta *big.Int, + currentLockupRate *big.Int, + debt *big.Int, + availableFunds *big.Int, + runwayEpochs int64, + bufferEpochs int64, + isNewDataSet bool, +) *big.Int { + combinedRate := new(big.Int).Add(currentLockupRate, rateDelta) + runway := new(big.Int).Mul(combinedRate, big.NewInt(runwayEpochs)) + + raw := new(big.Int).Add(additionalLockup, runway) + raw.Sub(raw, availableFunds) + raw.Add(raw, debt) + + if raw.Sign() < 0 { + raw.SetInt64(0) + } + + var buffer *big.Int + if currentLockupRate.Sign() == 0 && isNewDataSet { + buffer = new(big.Int) + } else { + buffer = new(big.Int).Mul(combinedRate, big.NewInt(bufferEpochs)) + } + + return new(big.Int).Add(raw, buffer) +} diff --git a/costs/calculate_test.go b/costs/calculate_test.go new file mode 100644 index 0000000..4a7e264 --- /dev/null +++ b/costs/calculate_test.go @@ -0,0 +1,319 @@ +package costs + +import ( + "math/big" + "testing" + + "github.com/data-preservation-programs/go-synapse/constants" + "github.com/data-preservation-programs/go-synapse/warmstorage" +) + +// Helper to create a *big.Int from int64. +func bi(v int64) *big.Int { return big.NewInt(v) } + +// usdfc returns n USDFC as attoUSDFC (n * 1e18). +func usdfc(n int64) *big.Int { + return new(big.Int).Mul(bi(n), big.NewInt(1e18)) +} + +// usdfcFrac returns a fractional USDFC amount: numerator/10 USDFC. +// e.g. usdfcFrac(1) = 0.1 USDFC, usdfcFrac(25) = 2.5 USDFC. +func usdfcFrac(tenths int64) *big.Int { + return new(big.Int).Mul(bi(tenths), big.NewInt(1e17)) +} + +func defaultPricing() *warmstorage.ServicePrice { + return &warmstorage.ServicePrice{ + PricePerTiBPerMonthNoCDN: usdfcFrac(25), // 2.5 USDFC/TiB/month + MinimumPricePerMonth: usdfcFrac(1), // 0.1 USDFC/month + EpochsPerMonth: bi(constants.EpochsPerMonth), + } +} + +// --- CalculateEffectiveRate --- + +func TestCalculateEffectiveRate_ExactOneTiB(t *testing.T) { + pricing := defaultPricing() + size := bi(constants.TiB) + + rate := CalculateEffectiveRate( + size, + pricing.PricePerTiBPerMonthNoCDN, + pricing.MinimumPricePerMonth, + pricing.EpochsPerMonth.Int64(), + ) + + // ratePerMonth = 2.5 USDFC * 1 TiB / 1 TiB = 2.5 USDFC + if rate.RatePerMonth.Cmp(usdfcFrac(25)) != 0 { + t.Errorf("ratePerMonth: got %s, want %s", rate.RatePerMonth, usdfcFrac(25)) + } + + // ratePerEpoch = 2.5 USDFC / 86400 epochs + expectedPerEpoch := new(big.Int).Div(usdfcFrac(25), bi(constants.EpochsPerMonth)) + if rate.RatePerEpoch.Cmp(expectedPerEpoch) != 0 { + t.Errorf("ratePerEpoch: got %s, want %s", rate.RatePerEpoch, expectedPerEpoch) + } +} + +func TestCalculateEffectiveRate_SubTiB_HitsMinimum(t *testing.T) { + pricing := defaultPricing() + // Very small size: 1 byte. Natural rate << minimum. + size := bi(1) + + rate := CalculateEffectiveRate( + size, + pricing.PricePerTiBPerMonthNoCDN, + pricing.MinimumPricePerMonth, + pricing.EpochsPerMonth.Int64(), + ) + + // Should hit minimum monthly rate + if rate.RatePerMonth.Cmp(pricing.MinimumPricePerMonth) != 0 { + t.Errorf("ratePerMonth should be minimum: got %s, want %s", + rate.RatePerMonth, pricing.MinimumPricePerMonth) + } + + // ratePerEpoch should be at least 1 + if rate.RatePerEpoch.Cmp(bi(1)) < 0 { + t.Errorf("ratePerEpoch should be at least 1: got %s", rate.RatePerEpoch) + } +} + +func TestCalculateEffectiveRate_MultiTiB(t *testing.T) { + pricing := defaultPricing() + size := new(big.Int).Mul(bi(5), bi(constants.TiB)) // 5 TiB + + rate := CalculateEffectiveRate( + size, + pricing.PricePerTiBPerMonthNoCDN, + pricing.MinimumPricePerMonth, + pricing.EpochsPerMonth.Int64(), + ) + + // ratePerMonth = 2.5 * 5 = 12.5 USDFC + expected := usdfcFrac(125) // 12.5 USDFC + if rate.RatePerMonth.Cmp(expected) != 0 { + t.Errorf("ratePerMonth: got %s, want %s", rate.RatePerMonth, expected) + } +} + +func TestCalculateEffectiveRate_ZeroSize(t *testing.T) { + pricing := defaultPricing() + rate := CalculateEffectiveRate( + bi(0), + pricing.PricePerTiBPerMonthNoCDN, + pricing.MinimumPricePerMonth, + pricing.EpochsPerMonth.Int64(), + ) + + // Should hit minimum + if rate.RatePerMonth.Cmp(pricing.MinimumPricePerMonth) != 0 { + t.Errorf("ratePerMonth should be minimum for zero size") + } +} + +// --- CalculateAdditionalLockupRequired --- + +func TestAdditionalLockup_NewDataSet(t *testing.T) { + pricing := defaultPricing() + sybilFee := usdfcFrac(1) // 0.1 USDFC + + lockup := CalculateAdditionalLockupRequired( + bi(constants.TiB), // uploading 1 TiB + bi(0), // empty dataset + pricing, + DefaultLockupPeriod, + sybilFee, + true, // new dataset + false, // no CDN + ) + + if lockup.RateDelta.Sign() <= 0 { + t.Errorf("rateDelta should be positive for new dataset: got %s", lockup.RateDelta) + } + + // TotalLockup should include sybil fee + minExpected := new(big.Int).Add( + new(big.Int).Mul(lockup.RateDelta, bi(DefaultLockupPeriod)), + sybilFee, + ) + if lockup.TotalLockup.Cmp(minExpected) != 0 { + t.Errorf("totalLockup: got %s, want %s", lockup.TotalLockup, minExpected) + } +} + +func TestAdditionalLockup_NewDataSet_WithCDN(t *testing.T) { + pricing := defaultPricing() + sybilFee := usdfcFrac(1) + + lockup := CalculateAdditionalLockupRequired( + bi(constants.TiB), + bi(0), + pricing, + DefaultLockupPeriod, + sybilFee, + true, // new dataset + true, // CDN enabled + ) + + // Should include CDN + CacheMiss + sybil fee + rateLockup := new(big.Int).Mul(lockup.RateDelta, bi(DefaultLockupPeriod)) + expected := new(big.Int).Add(rateLockup, CDNFixedLockup) + expected.Add(expected, CacheMissFixedLockup) + expected.Add(expected, sybilFee) + + if lockup.TotalLockup.Cmp(expected) != 0 { + t.Errorf("totalLockup with CDN: got %s, want %s", lockup.TotalLockup, expected) + } +} + +func TestAdditionalLockup_ExistingDataSet(t *testing.T) { + pricing := defaultPricing() + currentSize := bi(constants.TiB) // 1 TiB already stored + + lockup := CalculateAdditionalLockupRequired( + bi(constants.TiB), // adding 1 TiB + currentSize, + pricing, + DefaultLockupPeriod, + usdfcFrac(1), + false, // existing dataset + false, + ) + + // rateDelta = rate(2TiB) - rate(1TiB), should be positive + if lockup.RateDelta.Sign() < 0 { + t.Errorf("rateDelta should not be negative: got %s", lockup.RateDelta) + } + + // Should NOT include sybil fee or CDN lockup for existing dataset + expectedLockup := new(big.Int).Mul(lockup.RateDelta, bi(DefaultLockupPeriod)) + if lockup.TotalLockup.Cmp(expectedLockup) != 0 { + t.Errorf("totalLockup for existing dataset should not include sybil: got %s, want %s", + lockup.TotalLockup, expectedLockup) + } +} + +func TestAdditionalLockup_ExistingDataSet_NilSybilFee(t *testing.T) { + pricing := defaultPricing() + + lockup := CalculateAdditionalLockupRequired( + bi(constants.TiB), + bi(0), + pricing, + DefaultLockupPeriod, + nil, // nil sybil fee + true, // new dataset + false, + ) + + // Should not panic and should equal rateDelta * lockupPeriod + expectedLockup := new(big.Int).Mul(lockup.RateDelta, bi(DefaultLockupPeriod)) + if lockup.TotalLockup.Cmp(expectedLockup) != 0 { + t.Errorf("totalLockup with nil sybil: got %s, want %s", + lockup.TotalLockup, expectedLockup) + } +} + +// --- CalculateDepositNeeded --- + +func TestDepositNeeded_InsufficientFunds(t *testing.T) { + deposit := CalculateDepositNeeded( + usdfc(10), // additionalLockup + bi(100), // rateDelta per epoch + bi(50), // currentLockupRate per epoch + bi(0), // no debt + usdfc(1), // 1 USDFC available + DefaultRunwayEpochs, + DefaultBufferEpochs, + false, // existing dataset + ) + + if deposit.Sign() <= 0 { + t.Errorf("deposit should be positive when funds are insufficient: got %s", deposit) + } +} + +func TestDepositNeeded_SufficientFunds(t *testing.T) { + // Give a massive amount of available funds + huge := new(big.Int).Mul(usdfc(1000000), bi(1e18)) + deposit := CalculateDepositNeeded( + usdfc(1), // small lockup + bi(1), // tiny rate delta + bi(1), // tiny current rate + bi(0), // no debt + huge, // way more than enough + DefaultRunwayEpochs, + DefaultBufferEpochs, + false, + ) + + // Buffer is still added even when raw deposit is 0 + // But the raw part should be clamped to 0 + // deposit = max(raw, 0) + buffer + // raw = 1e18 + 2*259200 - huge + 0 → negative → 0 + // buffer = 2 * 86400 = positive + // So deposit > 0 due to buffer + if deposit.Sign() < 0 { + t.Errorf("deposit should not be negative: got %s", deposit) + } +} + +func TestDepositNeeded_WithDebt(t *testing.T) { + depositNoDebt := CalculateDepositNeeded( + usdfc(10), bi(100), bi(50), bi(0), usdfc(1), + DefaultRunwayEpochs, DefaultBufferEpochs, false, + ) + depositWithDebt := CalculateDepositNeeded( + usdfc(10), bi(100), bi(50), usdfc(5), usdfc(1), + DefaultRunwayEpochs, DefaultBufferEpochs, false, + ) + + if depositWithDebt.Cmp(depositNoDebt) <= 0 { + t.Errorf("deposit with debt should be larger: debt=%s, noDebt=%s", + depositWithDebt, depositNoDebt) + } +} + +func TestDepositNeeded_BufferSkipped_NewDataSet_ZeroRate(t *testing.T) { + depositNew := CalculateDepositNeeded( + usdfc(10), // additionalLockup + bi(100), // rateDelta + bi(0), // currentLockupRate = 0 + bi(0), // no debt + bi(0), // no available funds + DefaultRunwayEpochs, + DefaultBufferEpochs, + true, // new dataset → buffer should be skipped + ) + + depositExisting := CalculateDepositNeeded( + usdfc(10), + bi(100), + bi(0), + bi(0), + bi(0), + DefaultRunwayEpochs, + DefaultBufferEpochs, + false, // existing → buffer applied + ) + + // For new dataset with zero current rate, buffer is skipped. + // For existing, buffer = bufferEpochs * (0 + 100) = 86400 * 100 + // So depositExisting should be larger. + if depositNew.Cmp(depositExisting) >= 0 { + t.Errorf("new dataset with zero rate should skip buffer and be smaller: new=%s, existing=%s", + depositNew, depositExisting) + } +} + +func TestDepositNeeded_ZeroEverything(t *testing.T) { + deposit := CalculateDepositNeeded( + bi(0), bi(0), bi(0), bi(0), bi(0), + 0, 0, + true, + ) + if deposit.Sign() != 0 { + t.Errorf("deposit should be zero when all inputs are zero: got %s", deposit) + } +} diff --git a/costs/constants.go b/costs/constants.go new file mode 100644 index 0000000..613c6a9 --- /dev/null +++ b/costs/constants.go @@ -0,0 +1,18 @@ +package costs + +import ( + "math/big" + + "github.com/data-preservation-programs/go-synapse/constants" +) + +const ( + DefaultRunwayEpochs = 3 * constants.EpochsPerMonth // 3 months + DefaultBufferEpochs = constants.EpochsPerMonth // 1 month + DefaultLockupPeriod = constants.EpochsPerMonth // 1 month +) + +var ( + CDNFixedLockup = big.NewInt(700000000000000000) // 0.7 USDFC + CacheMissFixedLockup = big.NewInt(300000000000000000) // 0.3 USDFC +) diff --git a/costs/service.go b/costs/service.go new file mode 100644 index 0000000..055fc36 --- /dev/null +++ b/costs/service.go @@ -0,0 +1,317 @@ +package costs + +import ( + "context" + "fmt" + "math/big" + "strings" + "sync" + + "github.com/data-preservation-programs/go-synapse/constants" + "github.com/data-preservation-programs/go-synapse/contracts" + "github.com/data-preservation-programs/go-synapse/warmstorage" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" +) + +type Service struct { + ethClient *ethclient.Client + chainID int64 + fwss *warmstorage.FWSSContract + pdpVerifier *contracts.PDPVerifier + paymentsContract *contracts.PaymentsContract + usdfcAddress common.Address + fwssAddress common.Address + pdpVerifierAddr common.Address +} + +type ServiceConfig struct { + FWSSAddress common.Address + PDPVerifierAddress common.Address + PaymentsAddress common.Address + USDFCAddress common.Address +} + +func NewService(client *ethclient.Client, chainID int64, config ServiceConfig) (*Service, error) { + fwss, err := warmstorage.NewFWSSContract(config.FWSSAddress, client) + if err != nil { + return nil, fmt.Errorf("failed to create FWSS contract: %w", err) + } + + pdpVerifier, err := contracts.NewPDPVerifier(config.PDPVerifierAddress, client) + if err != nil { + return nil, fmt.Errorf("failed to create PDP verifier contract: %w", err) + } + + paymentsContract, err := contracts.NewPaymentsContract(config.PaymentsAddress, client) + if err != nil { + return nil, fmt.Errorf("failed to create payments contract: %w", err) + } + + return &Service{ + ethClient: client, + chainID: chainID, + fwss: fwss, + pdpVerifier: pdpVerifier, + paymentsContract: paymentsContract, + usdfcAddress: config.USDFCAddress, + fwssAddress: config.FWSSAddress, + pdpVerifierAddr: config.PDPVerifierAddress, + }, nil +} + +func (s *Service) GetServicePrice(ctx context.Context) (*warmstorage.ServicePrice, error) { + return s.fwss.GetServicePrice(ctx) +} + +// GetUploadCosts computes costs for uploading uploadSizeBytes to a dataset +// that currently holds dataSetSizeBytes (0 for new). +func (s *Service) GetUploadCosts( + ctx context.Context, + payer common.Address, + dataSetSizeBytes *big.Int, + uploadSizeBytes *big.Int, + opts *UploadCostOptions, +) (*UploadCosts, error) { + if opts == nil { + opts = &UploadCostOptions{} + } + runwayEpochs := opts.RunwayEpochs + if runwayEpochs == 0 { + runwayEpochs = DefaultRunwayEpochs + } + bufferEpochs := opts.BufferEpochs + if bufferEpochs == 0 { + bufferEpochs = DefaultBufferEpochs + } + + var ( + pricing *warmstorage.ServicePrice + acctFunds *big.Int + acctLockup *big.Int + acctRate *big.Int + acctSettle *big.Int + + fundedUntil *big.Int + currentFunds *big.Int + availableFunds *big.Int + currentRate *big.Int + + approved bool + rateAllowance *big.Int + lockAllowance *big.Int + rateUsed *big.Int + lockUsed *big.Int + maxLockPeriod *big.Int + + usdfcSybilFee *big.Int + + mu sync.Mutex + errs []error + wg sync.WaitGroup + ) + + wg.Add(4) + + go func() { + defer wg.Done() + p, err := s.fwss.GetServicePrice(ctx) + mu.Lock() + defer mu.Unlock() + if err != nil { + errs = append(errs, fmt.Errorf("getServicePrice: %w", err)) + return + } + pricing = p + }() + + go func() { + defer wg.Done() + f, l, r, st, err := s.paymentsContract.Accounts(ctx, s.usdfcAddress, payer) + if err != nil { + mu.Lock() + errs = append(errs, fmt.Errorf("accounts: %w", err)) + mu.Unlock() + return + } + fu, cf, af, cr, err2 := s.paymentsContract.GetAccountInfoIfSettled(ctx, s.usdfcAddress, payer) + mu.Lock() + defer mu.Unlock() + if err2 != nil { + errs = append(errs, fmt.Errorf("getAccountInfoIfSettled: %w", err2)) + return + } + acctFunds, acctLockup, acctRate, acctSettle = f, l, r, st + fundedUntil, currentFunds, availableFunds, currentRate = fu, cf, af, cr + }() + + go func() { + defer wg.Done() + a, ra, la, ru, lu, ml, err := s.paymentsContract.GetOperatorApproval( + ctx, s.usdfcAddress, payer, s.fwssAddress, + ) + mu.Lock() + defer mu.Unlock() + if err != nil { + errs = append(errs, fmt.Errorf("getOperatorApproval: %w", err)) + return + } + approved, rateAllowance, lockAllowance = a, ra, la + rateUsed, lockUsed, maxLockPeriod = ru, lu, ml + }() + + go func() { + defer wg.Done() + fee, err := s.readUsdfcSybilFee(ctx) + mu.Lock() + defer mu.Unlock() + if err != nil { + errs = append(errs, fmt.Errorf("usdfcSybilFee: %w", err)) + return + } + usdfcSybilFee = fee + }() + + wg.Wait() + + if len(errs) > 0 { + return nil, fmt.Errorf("failed to fetch contract state: %w", errs[0]) + } + + _ = acctFunds + _ = acctLockup + _ = acctRate + _ = acctSettle + _ = fundedUntil + _ = currentFunds + _ = rateUsed + _ = lockUsed + _ = rateAllowance + _ = lockAllowance + + totalSize := new(big.Int).Add(dataSetSizeBytes, uploadSizeBytes) + rate := CalculateEffectiveRate( + totalSize, + pricing.PricePerTiBPerMonthNoCDN, + pricing.MinimumPricePerMonth, + pricing.EpochsPerMonth.Int64(), + ) + + lockup := CalculateAdditionalLockupRequired( + uploadSizeBytes, + dataSetSizeBytes, + pricing, + DefaultLockupPeriod, + usdfcSybilFee, + opts.IsNewDataSet, + opts.EnableCDN, + ) + + debt := new(big.Int) + if availableFunds.Sign() < 0 { + debt.Neg(availableFunds) + } + + avail := new(big.Int) + if availableFunds.Sign() > 0 { + avail.Set(availableFunds) + } + + depositNeeded := CalculateDepositNeeded( + lockup.TotalLockup, + lockup.RateDelta, + currentRate, + debt, + avail, + runwayEpochs, + bufferEpochs, + opts.IsNewDataSet, + ) + + needsApproval := !approved || maxLockPeriod.Cmp(big.NewInt(DefaultLockupPeriod)) < 0 + + ready := depositNeeded.Sign() == 0 && !needsApproval + + return &UploadCosts{ + Rate: rate, + DepositNeeded: depositNeeded, + NeedsFWSSMaxApproval: needsApproval, + Ready: ready, + }, nil +} + +const usdfcSybilFeeABIJSON = `[{ + "type": "function", + "name": "USDFC_SYBIL_FEE", + "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view" +}]` + +var usdfcSybilFeeABI abi.ABI + +func init() { + var err error + usdfcSybilFeeABI, err = abi.JSON(strings.NewReader(usdfcSybilFeeABIJSON)) + if err != nil { + panic("failed to parse USDFC_SYBIL_FEE ABI: " + err.Error()) + } +} + +func (s *Service) readUsdfcSybilFee(ctx context.Context) (*big.Int, error) { + data, err := usdfcSybilFeeABI.Pack("USDFC_SYBIL_FEE") + if err != nil { + return nil, fmt.Errorf("failed to pack USDFC_SYBIL_FEE call: %w", err) + } + + result, err := s.ethClient.CallContract(ctx, ethereum.CallMsg{ + To: &s.pdpVerifierAddr, + Data: data, + }, nil) + if err != nil { + return nil, fmt.Errorf("failed to call USDFC_SYBIL_FEE: %w", err) + } + + values, err := usdfcSybilFeeABI.Unpack("USDFC_SYBIL_FEE", result) + if err != nil { + return nil, fmt.Errorf("failed to unpack USDFC_SYBIL_FEE result: %w", err) + } + + if len(values) == 0 { + return nil, fmt.Errorf("empty result from USDFC_SYBIL_FEE") + } + + fee, ok := values[0].(*big.Int) + if !ok { + return nil, fmt.Errorf("unexpected type for USDFC_SYBIL_FEE: %T", values[0]) + } + + return fee, nil +} + +func NetworkConfig(network constants.Network) (ServiceConfig, error) { + fwss, ok := constants.WarmStorageAddresses[network] + if !ok { + return ServiceConfig{}, fmt.Errorf("no FWSS address for network %s", network) + } + pdp, ok := constants.PDPVerifierAddresses[network] + if !ok { + return ServiceConfig{}, fmt.Errorf("no PDPVerifier address for network %s", network) + } + pay, ok := constants.PaymentsAddresses[network] + if !ok { + return ServiceConfig{}, fmt.Errorf("no Payments address for network %s", network) + } + usdfc, ok := constants.USDFCAddresses[network] + if !ok { + return ServiceConfig{}, fmt.Errorf("no USDFC address for network %s", network) + } + return ServiceConfig{ + FWSSAddress: fwss, + PDPVerifierAddress: pdp, + PaymentsAddress: pay, + USDFCAddress: usdfc, + }, nil +} diff --git a/costs/types.go b/costs/types.go new file mode 100644 index 0000000..bc4d429 --- /dev/null +++ b/costs/types.go @@ -0,0 +1,29 @@ +package costs + +import "math/big" + +type EffectiveRate struct { + // RatePerEpoch uses integer division to match on-chain Solidity truncation. + RatePerEpoch *big.Int + // RatePerMonth preserves more precision for display. + RatePerMonth *big.Int +} + +type AdditionalLockup struct { + RateDelta *big.Int + TotalLockup *big.Int +} + +type UploadCosts struct { + Rate EffectiveRate + DepositNeeded *big.Int + NeedsFWSSMaxApproval bool + Ready bool +} + +type UploadCostOptions struct { + RunwayEpochs int64 // defaults to DefaultRunwayEpochs (3 months) + BufferEpochs int64 // defaults to DefaultBufferEpochs (1 month) + EnableCDN bool + IsNewDataSet bool +} diff --git a/synapse.go b/synapse.go index a7f7727..daff797 100644 --- a/synapse.go +++ b/synapse.go @@ -9,6 +9,7 @@ import ( "math/big" "github.com/data-preservation-programs/go-synapse/constants" + "github.com/data-preservation-programs/go-synapse/costs" "github.com/data-preservation-programs/go-synapse/pdp" "github.com/data-preservation-programs/go-synapse/storage" "github.com/data-preservation-programs/go-synapse/warmstorage" @@ -39,6 +40,7 @@ type Client struct { address common.Address warmStorageAddress common.Address storageManager *storage.Manager + costsService *costs.Service providerURL string dataSetID int } @@ -158,6 +160,42 @@ func (c *Client) Storage() (*storage.Manager, error) { } +// Costs returns a lazily-initialized costs service for computing storage +// costs and deposit requirements. +func (c *Client) Costs() (*costs.Service, error) { + if c.costsService != nil { + return c.costsService, nil + } + + config, err := costs.NetworkConfig(constants.Network(c.network)) + if err != nil { + return nil, fmt.Errorf("failed to resolve costs config: %w", err) + } + + svc, err := costs.NewService(c.ethClient, c.chainID, config) + if err != nil { + return nil, fmt.Errorf("failed to create costs service: %w", err) + } + + c.costsService = svc + return c.costsService, nil +} + +// GetUploadCosts is a convenience wrapper that computes the cost summary for +// uploading data using the caller's address as payer. +func (c *Client) GetUploadCosts( + ctx context.Context, + dataSetSizeBytes *big.Int, + uploadSizeBytes *big.Int, + opts *costs.UploadCostOptions, +) (*costs.UploadCosts, error) { + svc, err := c.Costs() + if err != nil { + return nil, err + } + return svc.GetUploadCosts(ctx, c.address, dataSetSizeBytes, uploadSizeBytes, opts) +} + func (c *Client) Close() { if c.ethClient != nil { c.ethClient.Close() diff --git a/warmstorage/fwss.go b/warmstorage/fwss.go new file mode 100644 index 0000000..415f141 --- /dev/null +++ b/warmstorage/fwss.go @@ -0,0 +1,100 @@ +package warmstorage + +import ( + "context" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" +) + +const fwssABIJSON = `[ + { + "type": "function", + "name": "getServicePrice", + "inputs": [], + "outputs": [ + { + "name": "pricing", + "type": "tuple", + "components": [ + {"name": "pricePerTiBPerMonthNoCDN", "type": "uint256"}, + {"name": "pricePerTiBCdnEgress", "type": "uint256"}, + {"name": "pricePerTiBCacheMissEgress", "type": "uint256"}, + {"name": "tokenAddress", "type": "address"}, + {"name": "epochsPerMonth", "type": "uint256"}, + {"name": "minimumPricePerMonth", "type": "uint256"} + ] + } + ], + "stateMutability": "view" + } +]` + +type FWSSContract struct { + address common.Address + abi abi.ABI + client *ethclient.Client +} + +func NewFWSSContract(address common.Address, client *ethclient.Client) (*FWSSContract, error) { + parsedABI, err := abi.JSON(strings.NewReader(fwssABIJSON)) + if err != nil { + return nil, fmt.Errorf("failed to parse FWSS ABI: %w", err) + } + + return &FWSSContract{ + address: address, + abi: parsedABI, + client: client, + }, nil +} + +func (c *FWSSContract) GetServicePrice(ctx context.Context) (*ServicePrice, error) { + data, err := c.abi.Pack("getServicePrice") + if err != nil { + return nil, fmt.Errorf("failed to pack getServicePrice call: %w", err) + } + + result, err := c.client.CallContract(ctx, ethereum.CallMsg{ + To: &c.address, + Data: data, + }, nil) + if err != nil { + return nil, fmt.Errorf("failed to call getServicePrice: %w", err) + } + + values, err := c.abi.Unpack("getServicePrice", result) + if err != nil { + return nil, fmt.Errorf("failed to unpack getServicePrice result: %w", err) + } + + if len(values) == 0 { + return nil, fmt.Errorf("empty result from getServicePrice") + } + + pricing, ok := values[0].(struct { + PricePerTiBPerMonthNoCDN *big.Int `abi:"pricePerTiBPerMonthNoCDN"` + PricePerTiBCdnEgress *big.Int `abi:"pricePerTiBCdnEgress"` + PricePerTiBCacheMissEgress *big.Int `abi:"pricePerTiBCacheMissEgress"` + TokenAddress common.Address `abi:"tokenAddress"` + EpochsPerMonth *big.Int `abi:"epochsPerMonth"` + MinimumPricePerMonth *big.Int `abi:"minimumPricePerMonth"` + }) + if !ok { + return nil, fmt.Errorf("unexpected type for getServicePrice result: %T", values[0]) + } + + return &ServicePrice{ + PricePerTiBPerMonthNoCDN: pricing.PricePerTiBPerMonthNoCDN, + PricePerTiBCDNEgress: pricing.PricePerTiBCdnEgress, + PricePerTiBCacheMissEgress: pricing.PricePerTiBCacheMissEgress, + TokenAddress: pricing.TokenAddress, + EpochsPerMonth: pricing.EpochsPerMonth, + MinimumPricePerMonth: pricing.MinimumPricePerMonth, + }, nil +} diff --git a/warmstorage/types.go b/warmstorage/types.go index dcfedc8..9c0b49a 100644 --- a/warmstorage/types.go +++ b/warmstorage/types.go @@ -6,6 +6,15 @@ import ( "github.com/ethereum/go-ethereum/common" ) +type ServicePrice struct { + PricePerTiBPerMonthNoCDN *big.Int + PricePerTiBCDNEgress *big.Int + PricePerTiBCacheMissEgress *big.Int + TokenAddress common.Address + EpochsPerMonth *big.Int + MinimumPricePerMonth *big.Int +} + type DataSetInfo struct { PDPRailID *big.Int CacheMissRailID *big.Int From 6c102b4ba90082c471ae2eaed4646edd7843d032 Mon Sep 17 00:00:00 2001 From: Arkadiy Kukarkin Date: Sun, 22 Mar 2026 12:16:37 +0100 Subject: [PATCH 2/3] harmonize constants with synapse-sdk, lockup breakdown, account summary --- costs/calculate.go | 19 ++++-- costs/calculate_test.go | 37 +++++++---- costs/constants.go | 10 +-- costs/service.go | 137 +++++++++++++++++++++++++++------------- costs/types.go | 22 +++++-- 5 files changed, 153 insertions(+), 72 deletions(-) diff --git a/costs/calculate.go b/costs/calculate.go index eb123b7..628425e 100644 --- a/costs/calculate.go +++ b/costs/calculate.go @@ -81,20 +81,27 @@ func CalculateAdditionalLockupRequired( } } - totalLockup := new(big.Int).Mul(rateDelta, big.NewInt(lockupPeriod)) + rateLockup := new(big.Int).Mul(rateDelta, big.NewInt(lockupPeriod)) + cdnLockup := new(big.Int) if isNewDataSet && enableCDN { - totalLockup.Add(totalLockup, CDNFixedLockup) - totalLockup.Add(totalLockup, CacheMissFixedLockup) + cdnLockup.Set(CDNFixedLockup) } + sybilFee := new(big.Int) if isNewDataSet && usdfcSybilFee != nil { - totalLockup.Add(totalLockup, usdfcSybilFee) + sybilFee.Set(usdfcSybilFee) } + totalLockup := new(big.Int).Add(rateLockup, cdnLockup) + totalLockup.Add(totalLockup, sybilFee) + return AdditionalLockup{ - RateDelta: rateDelta, - TotalLockup: totalLockup, + RateDelta: rateDelta, + RateLockup: rateLockup, + CDNFixedLockup: cdnLockup, + SybilFee: sybilFee, + TotalLockup: totalLockup, } } diff --git a/costs/calculate_test.go b/costs/calculate_test.go index 4a7e264..361d912 100644 --- a/costs/calculate_test.go +++ b/costs/calculate_test.go @@ -156,15 +156,25 @@ func TestAdditionalLockup_NewDataSet_WithCDN(t *testing.T) { true, // CDN enabled ) - // Should include CDN + CacheMiss + sybil fee + // Should include CDN fixed lockup + sybil fee rateLockup := new(big.Int).Mul(lockup.RateDelta, bi(DefaultLockupPeriod)) expected := new(big.Int).Add(rateLockup, CDNFixedLockup) - expected.Add(expected, CacheMissFixedLockup) expected.Add(expected, sybilFee) if lockup.TotalLockup.Cmp(expected) != 0 { t.Errorf("totalLockup with CDN: got %s, want %s", lockup.TotalLockup, expected) } + + // verify component breakdown + if lockup.RateLockup.Cmp(rateLockup) != 0 { + t.Errorf("RateLockup: got %s, want %s", lockup.RateLockup, rateLockup) + } + if lockup.CDNFixedLockup.Cmp(CDNFixedLockup) != 0 { + t.Errorf("CDNFixedLockup: got %s, want %s", lockup.CDNFixedLockup, CDNFixedLockup) + } + if lockup.SybilFee.Cmp(sybilFee) != 0 { + t.Errorf("SybilFee: got %s, want %s", lockup.SybilFee, sybilFee) + } } func TestAdditionalLockup_ExistingDataSet(t *testing.T) { @@ -235,7 +245,7 @@ func TestDepositNeeded_InsufficientFunds(t *testing.T) { } func TestDepositNeeded_SufficientFunds(t *testing.T) { - // Give a massive amount of available funds + // give a massive amount of available funds huge := new(big.Int).Mul(usdfc(1000000), bi(1e18)) deposit := CalculateDepositNeeded( usdfc(1), // small lockup @@ -248,14 +258,13 @@ func TestDepositNeeded_SufficientFunds(t *testing.T) { false, ) - // Buffer is still added even when raw deposit is 0 - // But the raw part should be clamped to 0 - // deposit = max(raw, 0) + buffer - // raw = 1e18 + 2*259200 - huge + 0 → negative → 0 - // buffer = 2 * 86400 = positive - // So deposit > 0 due to buffer - if deposit.Sign() < 0 { - t.Errorf("deposit should not be negative: got %s", deposit) + // raw = lockup + runway - available + debt → negative → clamped to 0 + // buffer = (1+1) * 5 = 10 + // deposit = 0 + 10 = 10 + expectedBuffer := new(big.Int).Mul(bi(2), bi(DefaultBufferEpochs)) + if deposit.Cmp(expectedBuffer) != 0 { + t.Errorf("deposit should equal buffer when funds are sufficient: got %s, want %s", + deposit, expectedBuffer) } } @@ -298,9 +307,9 @@ func TestDepositNeeded_BufferSkipped_NewDataSet_ZeroRate(t *testing.T) { false, // existing → buffer applied ) - // For new dataset with zero current rate, buffer is skipped. - // For existing, buffer = bufferEpochs * (0 + 100) = 86400 * 100 - // So depositExisting should be larger. + // for new dataset with zero current rate, buffer is skipped. + // for existing, buffer = bufferEpochs * (0 + 100) = 5 * 100 = 500 + // so depositExisting should be larger. if depositNew.Cmp(depositExisting) >= 0 { t.Errorf("new dataset with zero rate should skip buffer and be smaller: new=%s, existing=%s", depositNew, depositExisting) diff --git a/costs/constants.go b/costs/constants.go index 613c6a9..4c80e52 100644 --- a/costs/constants.go +++ b/costs/constants.go @@ -7,12 +7,12 @@ import ( ) const ( - DefaultRunwayEpochs = 3 * constants.EpochsPerMonth // 3 months - DefaultBufferEpochs = constants.EpochsPerMonth // 1 month - DefaultLockupPeriod = constants.EpochsPerMonth // 1 month + DefaultRunwayEpochs = 0 // match synapse-sdk: no extra runway + DefaultBufferEpochs = 5 // match synapse-sdk: 5 epoch execution buffer + DefaultLockupPeriod = constants.EpochsPerMonth // 30 days ) var ( - CDNFixedLockup = big.NewInt(700000000000000000) // 0.7 USDFC - CacheMissFixedLockup = big.NewInt(300000000000000000) // 0.3 USDFC + CDNFixedLockup = big.NewInt(1000000000000000000) // 1.0 USDFC (combined CDN + cache miss) + UsdfcSybilFeeDefault = big.NewInt(100000000000000000) // 0.1 USDFC fallback ) diff --git a/costs/service.go b/costs/service.go index 055fc36..10228bc 100644 --- a/costs/service.go +++ b/costs/service.go @@ -16,6 +16,9 @@ import ( "github.com/ethereum/go-ethereum/ethclient" ) +var maxUint256 = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)) +var halfMaxUint256 = new(big.Int).Rsh(maxUint256, 1) + type Service struct { ethClient *ethclient.Client chainID int64 @@ -66,6 +69,25 @@ func (s *Service) GetServicePrice(ctx context.Context) (*warmstorage.ServicePric return s.fwss.GetServicePrice(ctx) } +// isFWSSMaxApproved checks all approval conditions matching synapse-sdk is-fwss-max-approved.ts: +// approved, rateAllowance == maxUint256, lockupAllowance >= maxUint256/2, maxLockPeriod >= 30 days +func isFWSSMaxApproved(approved bool, rateAllowance, lockAllowance, maxLockPeriod *big.Int) bool { + if !approved { + return false + } + if rateAllowance.Cmp(maxUint256) != 0 { + return false + } + // threshold, not exact -- contract decrements lockupAllowance on CDN payments + if lockAllowance.Cmp(halfMaxUint256) < 0 { + return false + } + if maxLockPeriod.Cmp(big.NewInt(DefaultLockupPeriod)) < 0 { + return false + } + return true +} + // GetUploadCosts computes costs for uploading uploadSizeBytes to a dataset // that currently holds dataSetSizeBytes (0 for new). func (s *Service) GetUploadCosts( @@ -79,7 +101,7 @@ func (s *Service) GetUploadCosts( opts = &UploadCostOptions{} } runwayEpochs := opts.RunwayEpochs - if runwayEpochs == 0 { + if runwayEpochs == 0 && DefaultRunwayEpochs > 0 { runwayEpochs = DefaultRunwayEpochs } bufferEpochs := opts.BufferEpochs @@ -88,23 +110,14 @@ func (s *Service) GetUploadCosts( } var ( - pricing *warmstorage.ServicePrice - acctFunds *big.Int - acctLockup *big.Int - acctRate *big.Int - acctSettle *big.Int - - fundedUntil *big.Int - currentFunds *big.Int + pricing *warmstorage.ServicePrice availableFunds *big.Int currentRate *big.Int - approved bool - rateAllowance *big.Int - lockAllowance *big.Int - rateUsed *big.Int - lockUsed *big.Int - maxLockPeriod *big.Int + approved bool + rateAllowance *big.Int + lockAllowance *big.Int + maxLockPeriod *big.Int usdfcSybilFee *big.Int @@ -129,27 +142,19 @@ func (s *Service) GetUploadCosts( go func() { defer wg.Done() - f, l, r, st, err := s.paymentsContract.Accounts(ctx, s.usdfcAddress, payer) - if err != nil { - mu.Lock() - errs = append(errs, fmt.Errorf("accounts: %w", err)) - mu.Unlock() - return - } - fu, cf, af, cr, err2 := s.paymentsContract.GetAccountInfoIfSettled(ctx, s.usdfcAddress, payer) + _, _, af, cr, err := s.paymentsContract.GetAccountInfoIfSettled(ctx, s.usdfcAddress, payer) mu.Lock() defer mu.Unlock() - if err2 != nil { - errs = append(errs, fmt.Errorf("getAccountInfoIfSettled: %w", err2)) + if err != nil { + errs = append(errs, fmt.Errorf("getAccountInfoIfSettled: %w", err)) return } - acctFunds, acctLockup, acctRate, acctSettle = f, l, r, st - fundedUntil, currentFunds, availableFunds, currentRate = fu, cf, af, cr + availableFunds, currentRate = af, cr }() go func() { defer wg.Done() - a, ra, la, ru, lu, ml, err := s.paymentsContract.GetOperatorApproval( + a, ra, la, _, _, ml, err := s.paymentsContract.GetOperatorApproval( ctx, s.usdfcAddress, payer, s.fwssAddress, ) mu.Lock() @@ -158,8 +163,7 @@ func (s *Service) GetUploadCosts( errs = append(errs, fmt.Errorf("getOperatorApproval: %w", err)) return } - approved, rateAllowance, lockAllowance = a, ra, la - rateUsed, lockUsed, maxLockPeriod = ru, lu, ml + approved, rateAllowance, lockAllowance, maxLockPeriod = a, ra, la, ml }() go func() { @@ -180,17 +184,6 @@ func (s *Service) GetUploadCosts( return nil, fmt.Errorf("failed to fetch contract state: %w", errs[0]) } - _ = acctFunds - _ = acctLockup - _ = acctRate - _ = acctSettle - _ = fundedUntil - _ = currentFunds - _ = rateUsed - _ = lockUsed - _ = rateAllowance - _ = lockAllowance - totalSize := new(big.Int).Add(dataSetSizeBytes, uploadSizeBytes) rate := CalculateEffectiveRate( totalSize, @@ -230,18 +223,76 @@ func (s *Service) GetUploadCosts( opts.IsNewDataSet, ) - needsApproval := !approved || maxLockPeriod.Cmp(big.NewInt(DefaultLockupPeriod)) < 0 - + needsApproval := !isFWSSMaxApproved(approved, rateAllowance, lockAllowance, maxLockPeriod) ready := depositNeeded.Sign() == 0 && !needsApproval return &UploadCosts{ Rate: rate, + Lockup: lockup, DepositNeeded: depositNeeded, NeedsFWSSMaxApproval: needsApproval, Ready: ready, }, nil } +// GetAccountSummary returns the account health snapshot for the given address. +func (s *Service) GetAccountSummary(ctx context.Context, owner common.Address) (*AccountSummary, error) { + var ( + fundedUntil *big.Int + currentFunds *big.Int + availableFunds *big.Int + currentRate *big.Int + + mu sync.Mutex + errs []error + wg sync.WaitGroup + ) + + wg.Add(1) + + go func() { + defer wg.Done() + fu, cf, af, cr, err := s.paymentsContract.GetAccountInfoIfSettled(ctx, s.usdfcAddress, owner) + mu.Lock() + defer mu.Unlock() + if err != nil { + errs = append(errs, fmt.Errorf("getAccountInfoIfSettled: %w", err)) + return + } + fundedUntil, currentFunds, availableFunds, currentRate = fu, cf, af, cr + }() + + wg.Wait() + + if len(errs) > 0 { + return nil, fmt.Errorf("failed to fetch account state: %w", errs[0]) + } + + debt := new(big.Int) + if availableFunds.Sign() < 0 { + debt.Neg(availableFunds) + } + + funds := new(big.Int) + if currentFunds.Sign() > 0 { + funds.Set(currentFunds) + } + + ratePerMonth := new(big.Int).Mul(currentRate, big.NewInt(constants.EpochsPerMonth)) + + currentEpoch := constants.CurrentEpoch(s.chainID) + + return &AccountSummary{ + Funds: funds, + AvailableFunds: availableFunds, + Debt: debt, + LockupRatePerEpoch: currentRate, + LockupRatePerMonth: ratePerMonth, + FundedUntilEpoch: fundedUntil, + CurrentEpoch: currentEpoch, + }, nil +} + const usdfcSybilFeeABIJSON = `[{ "type": "function", "name": "USDFC_SYBIL_FEE", diff --git a/costs/types.go b/costs/types.go index bc4d429..609def0 100644 --- a/costs/types.go +++ b/costs/types.go @@ -10,20 +10,34 @@ type EffectiveRate struct { } type AdditionalLockup struct { - RateDelta *big.Int - TotalLockup *big.Int + RateDelta *big.Int + RateLockup *big.Int // rateDelta * lockupPeriod + CDNFixedLockup *big.Int // 1.0 USDFC for new CDN datasets, 0 otherwise + SybilFee *big.Int // sybil fee for new datasets, 0 otherwise + TotalLockup *big.Int // sum of all components } type UploadCosts struct { Rate EffectiveRate + Lockup AdditionalLockup DepositNeeded *big.Int NeedsFWSSMaxApproval bool Ready bool } type UploadCostOptions struct { - RunwayEpochs int64 // defaults to DefaultRunwayEpochs (3 months) - BufferEpochs int64 // defaults to DefaultBufferEpochs (1 month) + RunwayEpochs int64 // defaults to DefaultRunwayEpochs (0) + BufferEpochs int64 // defaults to DefaultBufferEpochs (5 epochs) EnableCDN bool IsNewDataSet bool } + +type AccountSummary struct { + Funds *big.Int + AvailableFunds *big.Int + Debt *big.Int + LockupRatePerEpoch *big.Int + LockupRatePerMonth *big.Int + FundedUntilEpoch *big.Int + CurrentEpoch *big.Int +} From 45286ab827fbdd968e9121e3733c33782978f73c Mon Sep 17 00:00:00 2001 From: Arkadiy Kukarkin Date: Wed, 25 Mar 2026 13:48:32 +0100 Subject: [PATCH 3/3] add isFWSSMaxApproved and lockup breakdown tests --- costs/calculate_test.go | 108 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/costs/calculate_test.go b/costs/calculate_test.go index 361d912..046736f 100644 --- a/costs/calculate_test.go +++ b/costs/calculate_test.go @@ -326,3 +326,111 @@ func TestDepositNeeded_ZeroEverything(t *testing.T) { t.Errorf("deposit should be zero when all inputs are zero: got %s", deposit) } } + +// --- isFWSSMaxApproved --- + +func TestIsFWSSMaxApproved_AllConditionsMet(t *testing.T) { + ok := isFWSSMaxApproved(true, maxUint256, maxUint256, bi(DefaultLockupPeriod)) + if !ok { + t.Error("should be approved when all conditions met") + } +} + +func TestIsFWSSMaxApproved_NotApproved(t *testing.T) { + ok := isFWSSMaxApproved(false, maxUint256, maxUint256, bi(DefaultLockupPeriod)) + if ok { + t.Error("should not be approved when approved=false") + } +} + +func TestIsFWSSMaxApproved_RateAllowanceTooLow(t *testing.T) { + lowRate := new(big.Int).Sub(maxUint256, bi(1)) + ok := isFWSSMaxApproved(true, lowRate, maxUint256, bi(DefaultLockupPeriod)) + if ok { + t.Error("should not be approved when rateAllowance < maxUint256") + } +} + +func TestIsFWSSMaxApproved_LockAllowanceAtThreshold(t *testing.T) { + // exactly at threshold -- should pass + ok := isFWSSMaxApproved(true, maxUint256, halfMaxUint256, bi(DefaultLockupPeriod)) + if !ok { + t.Error("should be approved at lockAllowance == maxUint256/2") + } +} + +func TestIsFWSSMaxApproved_LockAllowanceBelowThreshold(t *testing.T) { + belowHalf := new(big.Int).Sub(halfMaxUint256, bi(1)) + ok := isFWSSMaxApproved(true, maxUint256, belowHalf, bi(DefaultLockupPeriod)) + if ok { + t.Error("should not be approved when lockAllowance < maxUint256/2") + } +} + +func TestIsFWSSMaxApproved_MaxLockPeriodTooShort(t *testing.T) { + ok := isFWSSMaxApproved(true, maxUint256, maxUint256, bi(DefaultLockupPeriod-1)) + if ok { + t.Error("should not be approved when maxLockPeriod < DefaultLockupPeriod") + } +} + +// --- AdditionalLockup breakdown --- + +func TestAdditionalLockup_Breakdown_ExistingDataSet(t *testing.T) { + pricing := defaultPricing() + sybilFee := usdfcFrac(1) + + lockup := CalculateAdditionalLockupRequired( + bi(constants.TiB), + bi(constants.TiB), + pricing, + DefaultLockupPeriod, + sybilFee, + false, // existing + true, // CDN + ) + + // existing dataset: no CDN lockup, no sybil fee + if lockup.CDNFixedLockup.Sign() != 0 { + t.Errorf("CDNFixedLockup should be 0 for existing dataset: got %s", lockup.CDNFixedLockup) + } + if lockup.SybilFee.Sign() != 0 { + t.Errorf("SybilFee should be 0 for existing dataset: got %s", lockup.SybilFee) + } + + // total = rateLockup only + if lockup.TotalLockup.Cmp(lockup.RateLockup) != 0 { + t.Errorf("TotalLockup should equal RateLockup for existing dataset: total=%s, rate=%s", + lockup.TotalLockup, lockup.RateLockup) + } +} + +func TestAdditionalLockup_Breakdown_SumsCorrectly(t *testing.T) { + pricing := defaultPricing() + sybilFee := usdfcFrac(1) + + lockup := CalculateAdditionalLockupRequired( + bi(constants.TiB), + bi(0), + pricing, + DefaultLockupPeriod, + sybilFee, + true, // new dataset + true, // CDN + ) + + // verify total = rateLockup + cdnFixedLockup + sybilFee + expected := new(big.Int).Add(lockup.RateLockup, lockup.CDNFixedLockup) + expected.Add(expected, lockup.SybilFee) + if lockup.TotalLockup.Cmp(expected) != 0 { + t.Errorf("TotalLockup != sum of components: total=%s, sum=%s", + lockup.TotalLockup, expected) + } + + // verify rateLockup = rateDelta * lockupPeriod + expectedRate := new(big.Int).Mul(lockup.RateDelta, bi(DefaultLockupPeriod)) + if lockup.RateLockup.Cmp(expectedRate) != 0 { + t.Errorf("RateLockup != rateDelta * lockupPeriod: got %s, want %s", + lockup.RateLockup, expectedRate) + } +}