diff --git a/service/dealpusher/ddo_api.go b/service/dealpusher/ddo_api.go index 9567daf4..3f4b7779 100644 --- a/service/dealpusher/ddo_api.go +++ b/service/dealpusher/ddo_api.go @@ -3,9 +3,11 @@ package dealpusher import ( "context" "errors" + "math/big" "time" "github.com/data-preservation-programs/go-synapse/signer" + "github.com/ethereum/go-ethereum/common" "github.com/ipfs/go-cid" ) @@ -52,6 +54,11 @@ type DDODealManager interface { // DDO contract, and returns its on-chain config. ValidateSP(ctx context.Context, providerActorID uint64) (*DDOSPConfig, error) + // CheckBalance queries the wallet's native FIL and payment token balances, + // as well as the payments contract account status. Returns a summary that + // the scheduler uses for pre-flight logging and low-balance warnings. + CheckBalance(ctx context.Context, walletAddr common.Address) (*DDOBalanceStatus, error) + // EnsurePayments checks account balance and operator approval, deposits // and approves if needed. Takes the actual pieces (not aggregated totals) // because the SDK computes per-piece lockup: allocationLockupAmount * len(pieces). @@ -95,3 +102,13 @@ type DDOTransactionReceipt struct { GasUsed uint64 Status uint64 } + +// DDOBalanceStatus summarizes a wallet's balance state for DDO deal-making. +type DDOBalanceStatus struct { + WalletAddr common.Address + NativeFIL *big.Int // native FIL balance (for gas) + TokenBalance *big.Int // payment token balance held in wallet + DepositedFunds *big.Int // funds already deposited in payments contract + LockupCurrent *big.Int // current lockup in payments contract + Available *big.Int // deposited - lockup (spendable for new deals) +} diff --git a/service/dealpusher/ddo_onchain.go b/service/dealpusher/ddo_onchain.go index de73d7c5..a9e9007c 100644 --- a/service/dealpusher/ddo_onchain.go +++ b/service/dealpusher/ddo_onchain.go @@ -88,6 +88,73 @@ func (o *OnChainDDO) Close() { } } +func (o *OnChainDDO) CheckBalance(ctx context.Context, walletAddr common.Address) (*DDOBalanceStatus, error) { + ethClient := o.ddoClient.GetEthClient() + + // Native FIL balance (for gas) + nativeBal, err := ethClient.BalanceAt(ctx, walletAddr, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to query native FIL balance") + } + + // Payment token (USDFC) balance via balanceOf call + tokenBal, err := o.queryERC20Balance(ctx, o.paymentToken, walletAddr) + if err != nil { + return nil, errors.Wrap(err, "failed to query payment token balance") + } + + // Payments contract account status — use transactor constructor with + // read-only auth (we only call GetAccount, no writes). + paymentsClient, err := paymentscontract.NewClientWithTransactor( + ethClient, + o.paymentsContractAddr.Hex(), + &bind.TransactOpts{From: walletAddr}, + ) + if err != nil { + return nil, errors.Wrap(err, "failed to create payments client for balance query") + } + + account, err := paymentsClient.GetAccount(o.paymentToken, walletAddr) + if err != nil { + return nil, errors.Wrap(err, "failed to query payments contract account") + } + + available := new(big.Int).Sub(account.Funds, account.LockupCurrent) + if available.Sign() < 0 { + available = big.NewInt(0) + } + + return &DDOBalanceStatus{ + WalletAddr: walletAddr, + NativeFIL: nativeBal, + TokenBalance: tokenBal, + DepositedFunds: account.Funds, + LockupCurrent: account.LockupCurrent, + Available: available, + }, nil +} + +// queryERC20Balance calls balanceOf on an ERC20 token contract. +func (o *OnChainDDO) queryERC20Balance(ctx context.Context, tokenAddr, account common.Address) (*big.Int, error) { + // balanceOf(address) selector = 0x70a08231 + selector := common.FromHex("0x70a08231") + data := make([]byte, 4+32) + copy(data[0:4], selector) + copy(data[4+12:4+32], account.Bytes()) + + result, err := o.ddoClient.GetEthClient().CallContract(ctx, ethereum.CallMsg{ + To: &tokenAddr, + Data: data, + }, nil) + if err != nil { + return nil, err + } + if len(result) < 32 { + return big.NewInt(0), nil + } + return new(big.Int).SetBytes(result), nil +} + func (o *OnChainDDO) ValidateSP(ctx context.Context, providerActorID uint64) (*DDOSPConfig, error) { cfg, err := o.ddoClient.GetSPConfig(providerActorID) if err != nil { diff --git a/service/dealpusher/ddo_schedule.go b/service/dealpusher/ddo_schedule.go index ec5b2542..00b0462f 100644 --- a/service/dealpusher/ddo_schedule.go +++ b/service/dealpusher/ddo_schedule.go @@ -3,6 +3,7 @@ package dealpusher import ( "context" "fmt" + "math/big" "strconv" "strings" "time" @@ -17,6 +18,10 @@ import ( "gorm.io/gorm" ) +// minGasBalance is the minimum native FIL balance before a low-gas warning +// is emitted. 0.1 FIL = 10^17 attoFIL. +var minGasBalance = new(big.Int).Exp(big.NewInt(10), big.NewInt(17), nil) + func defaultDDOSchedulingConfig() DDOSchedulingConfig { return DDOSchedulingConfig{ BatchSize: 10, @@ -98,6 +103,36 @@ func (d *DealPusher) runDDOSchedule(ctx context.Context, schedule *model.Schedul return model.ScheduleError, fmt.Errorf("provider %s (actor %d) is not active in the DDO contract", schedule.Provider, providerActorID) } + // Pre-flight balance check — log wallet state for observability + // and warn if gas balance is dangerously low. + if balStatus, balErr := d.ddoDealManager.CheckBalance(ctx, evmSigner.EVMAddress()); balErr != nil { + Logger.Warnw("failed to check wallet balance before DDO scheduling", + "schedule", schedule.ID, "wallet", evmSigner.EVMAddress().Hex(), "error", balErr) + } else { + Logger.Infow("DDO wallet balance", + "schedule", schedule.ID, + "wallet", evmSigner.EVMAddress().Hex(), + "nativeFIL", balStatus.NativeFIL.String(), + "paymentToken", balStatus.TokenBalance.String(), + "deposited", balStatus.DepositedFunds.String(), + "available", balStatus.Available.String(), + ) + if balStatus.NativeFIL.Cmp(minGasBalance) < 0 { + Logger.Warnw("wallet FIL balance low — may not have enough gas for DDO transactions", + "schedule", schedule.ID, + "wallet", evmSigner.EVMAddress().Hex(), + "balance", balStatus.NativeFIL.String(), + "threshold", minGasBalance.String(), + ) + } + if balStatus.TokenBalance.Sign() == 0 && balStatus.Available.Sign() == 0 { + Logger.Warnw("wallet has no payment tokens and no deposited funds — EnsurePayments will fail", + "schedule", schedule.ID, + "wallet", evmSigner.EVMAddress().Hex(), + ) + } + } + var timer *time.Timer current := sumResult{} for { diff --git a/service/dealpusher/ddo_schedule_test.go b/service/dealpusher/ddo_schedule_test.go index 24a83eba..a4cd32a7 100644 --- a/service/dealpusher/ddo_schedule_test.go +++ b/service/dealpusher/ddo_schedule_test.go @@ -2,6 +2,7 @@ package dealpusher import ( "context" + "math/big" "testing" "time" @@ -9,6 +10,7 @@ import ( "github.com/data-preservation-programs/singularity/model" "github.com/data-preservation-programs/singularity/util/keystore" "github.com/data-preservation-programs/singularity/util/testutil" + "github.com/ethereum/go-ethereum/common" "github.com/filecoin-project/go-address" "github.com/ipfs/go-cid" "github.com/stretchr/testify/require" @@ -26,6 +28,16 @@ func (m *ddoDealManagerMock) ValidateSP(_ context.Context, _ uint64) (*DDOSPConf return m.spConfig, nil } +func (m *ddoDealManagerMock) CheckBalance(_ context.Context, _ common.Address) (*DDOBalanceStatus, error) { + return &DDOBalanceStatus{ + NativeFIL: new(big.Int).Mul(big.NewInt(10), big.NewInt(1e18)), + TokenBalance: new(big.Int).Mul(big.NewInt(1000), big.NewInt(1e18)), + DepositedFunds: big.NewInt(0), + LockupCurrent: big.NewInt(0), + Available: big.NewInt(0), + }, nil +} + func (m *ddoDealManagerMock) EnsurePayments(_ context.Context, _ signer.EVMSigner, _ []DDOPieceSubmission, _ DDOSchedulingConfig) error { m.ensureCalled = true return nil