From 5df0b4e60c554c900ea260782f913e9ee2dd9a1e Mon Sep 17 00:00:00 2001 From: anjor Date: Fri, 20 Mar 2026 12:13:42 +0000 Subject: [PATCH 1/3] Add f05 paid deal scaffolding --- client/swagger/models/model_deal.go | 9 +++++ client/swagger/models/model_deal_type.go | 5 ++- .../swagger/models/schedule_create_request.go | 2 +- cmd/deal/list.go | 2 +- cmd/deal/schedule/create.go | 2 +- cmd/deal/schedule/update.go | 2 +- docs/swagger/docs.go | 14 +++++++- docs/swagger/swagger.json | 16 +++++++-- docs/swagger/swagger.yaml | 11 +++++- handler/deal/list.go | 2 +- handler/deal/schedule/create.go | 2 +- handler/deal/schedule/create_test.go | 13 +++++++ handler/deal/schedule/update.go | 2 +- handler/deal/schedule/update_test.go | 13 +++++++ model/replication.go | 11 +++++- service/dealpusher/dealpusher.go | 6 ++++ service/dealpusher/f05paid_api.go | 14 ++++++++ service/dealpusher/options.go | 6 ++++ service/dealpusher/pdp_wiring_test.go | 35 +++++++++++++++++++ 19 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 service/dealpusher/f05paid_api.go diff --git a/client/swagger/models/model_deal.go b/client/swagger/models/model_deal.go index 71b6e8b3..20efa662 100644 --- a/client/swagger/models/model_deal.go +++ b/client/swagger/models/model_deal.go @@ -25,6 +25,15 @@ type ModelDeal struct { // created at CreatedAt string `json:"createdAt,omitempty"` + // F05 paid-deal fields (only populated for DealTypeF05Paid) + F05PaymentContract string `json:"f05PaymentContract,omitempty"` + + // f05 payment status + F05PaymentStatus string `json:"f05PaymentStatus,omitempty"` + + // f05 payment transaction hash + F05PaymentTxHash string `json:"f05PaymentTxHash,omitempty"` + // DDO-specific fields (only populated for DealTypeDDO) DdoAllocationID int64 `json:"ddoAllocationId,omitempty"` diff --git a/client/swagger/models/model_deal_type.go b/client/swagger/models/model_deal_type.go index 4af0305a..85900575 100644 --- a/client/swagger/models/model_deal_type.go +++ b/client/swagger/models/model_deal_type.go @@ -33,6 +33,9 @@ const ( // ModelDealTypeMarket captures enum value "market" ModelDealTypeMarket ModelDealType = "market" + // ModelDealTypeF05Paid captures enum value "f05_paid" + ModelDealTypeF05Paid ModelDealType = "f05_paid" + // ModelDealTypePdp captures enum value "pdp" ModelDealTypePdp ModelDealType = "pdp" @@ -45,7 +48,7 @@ var modelDealTypeEnum []any func init() { var res []ModelDealType - if err := json.Unmarshal([]byte(`["market","pdp","ddo"]`), &res); err != nil { + if err := json.Unmarshal([]byte(`["market","f05_paid","pdp","ddo"]`), &res); err != nil { panic(err) } for _, v := range res { diff --git a/client/swagger/models/schedule_create_request.go b/client/swagger/models/schedule_create_request.go index e6e7cf6e..ebbeaeb8 100644 --- a/client/swagger/models/schedule_create_request.go +++ b/client/swagger/models/schedule_create_request.go @@ -20,7 +20,7 @@ type ScheduleCreateRequest struct { // Allowed piece CIDs in this schedule AllowedPieceCids []string `json:"allowedPieceCids"` - // Deal type: market (f05), pdp (f41), or ddo + // Deal type: market (legacy f05), f05_paid (f05 with on-chain payments), pdp (f41), or ddo DealType string `json:"dealType,omitempty"` // Duration in epoch or in duration format, i.e. 1500000, 2400h diff --git a/cmd/deal/list.go b/cmd/deal/list.go index 394717eb..1d7d4a2a 100644 --- a/cmd/deal/list.go +++ b/cmd/deal/list.go @@ -36,7 +36,7 @@ var ListCmd = &cli.Command{ }, &cli.StringSliceFlag{ Name: "deal-type", - Usage: "Filter deals by type: market (legacy f05), pdp (f41 PDP deals)", + Usage: "Filter deals by type: market, f05_paid, pdp, ddo", }, }, Action: func(c *cli.Context) error { diff --git a/cmd/deal/schedule/create.go b/cmd/deal/schedule/create.go index 3bec5a13..07526380 100644 --- a/cmd/deal/schedule/create.go +++ b/cmd/deal/schedule/create.go @@ -62,7 +62,7 @@ var CreateCmd = &cli.Command{ &cli.StringFlag{ Name: "deal-type", Category: "Deal Proposal", - Usage: "Deal type: market (legacy f05), pdp (f41), or ddo (DDO allocations)", + Usage: "Deal type: market (legacy f05), f05_paid (f05 with on-chain payments), pdp (f41), or ddo (DDO allocations)", Value: string(model.DealTypeMarket), }, &cli.StringSliceFlag{ diff --git a/cmd/deal/schedule/update.go b/cmd/deal/schedule/update.go index 89c569ad..d9e75dee 100644 --- a/cmd/deal/schedule/update.go +++ b/cmd/deal/schedule/update.go @@ -86,7 +86,7 @@ var UpdateCmd = &cli.Command{ &cli.StringFlag{ Name: "deal-type", Category: "Deal Proposal", - Usage: "Deal type: market (legacy f05) or pdp (f41)", + Usage: "Deal type: market (legacy f05), f05_paid (f05 with on-chain payments), pdp (f41), or ddo (DDO allocations)", }, &cli.BoolFlag{ Name: "ipni", diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 3cddffd7..91e464dc 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -6493,6 +6493,16 @@ const docTemplate = `{ "createdAt": { "type": "string" }, + "f05PaymentContract": { + "description": "F05 paid-deal fields (only populated for DealTypeF05Paid)", + "type": "string" + }, + "f05PaymentStatus": { + "type": "string" + }, + "f05PaymentTxHash": { + "type": "string" + }, "ddoAllocationId": { "description": "DDO-specific fields (only populated for DealTypeDDO)", "type": "integer" @@ -6603,11 +6613,13 @@ const docTemplate = `{ "type": "string", "enum": [ "market", + "f05_paid", "pdp", "ddo" ], "x-enum-varnames": [ "DealTypeMarket", + "DealTypeF05Paid", "DealTypePDP", "DealTypeDDO" ] @@ -6994,7 +7006,7 @@ const docTemplate = `{ } }, "dealType": { - "description": "Deal type: market (f05), pdp (f41), or ddo", + "description": "Deal type: market (legacy f05), f05_paid (f05 with on-chain payments), pdp (f41), or ddo", "type": "string" }, "duration": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index adb43358..c9c9d1bd 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -6486,6 +6486,16 @@ "createdAt": { "type": "string" }, + "f05PaymentContract": { + "description": "F05 paid-deal fields (only populated for DealTypeF05Paid)", + "type": "string" + }, + "f05PaymentStatus": { + "type": "string" + }, + "f05PaymentTxHash": { + "type": "string" + }, "ddoAllocationId": { "description": "DDO-specific fields (only populated for DealTypeDDO)", "type": "integer" @@ -6596,11 +6606,13 @@ "type": "string", "enum": [ "market", + "f05_paid", "pdp", "ddo" ], "x-enum-varnames": [ "DealTypeMarket", + "DealTypeF05Paid", "DealTypePDP", "DealTypeDDO" ] @@ -6987,7 +6999,7 @@ } }, "dealType": { - "description": "Deal type: market (f05), pdp (f41), or ddo", + "description": "Deal type: market (legacy f05), f05_paid (f05 with on-chain payments), pdp (f41), or ddo", "type": "string" }, "duration": { @@ -20380,4 +20392,4 @@ "description": "OpenAPI", "url": "https://swagger.io/resources/open-api/" } -} \ No newline at end of file +} diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index ba513bd8..ba0bdb7b 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -395,6 +395,13 @@ definitions: type: string createdAt: type: string + f05PaymentContract: + description: F05 paid-deal fields (only populated for DealTypeF05Paid) + type: string + f05PaymentStatus: + type: string + f05PaymentTxHash: + type: string ddoAllocationId: description: DDO-specific fields (only populated for DealTypeDDO) type: integer @@ -478,11 +485,13 @@ definitions: model.DealType: enum: - market + - f05_paid - pdp - ddo type: string x-enum-varnames: - DealTypeMarket + - DealTypeF05Paid - DealTypePDP - DealTypeDDO model.File: @@ -754,7 +763,7 @@ definitions: type: string type: array dealType: - description: 'Deal type: market (f05), pdp (f41), or ddo' + description: 'Deal type: market (legacy f05), f05_paid (f05 with on-chain payments), pdp (f41), or ddo' type: string duration: default: 12840h diff --git a/handler/deal/list.go b/handler/deal/list.go index 2977b79b..93d115f0 100644 --- a/handler/deal/list.go +++ b/handler/deal/list.go @@ -15,7 +15,7 @@ type ListDealRequest struct { Schedules []uint32 `json:"schedules"` // schedule id filter Providers []string `json:"providers"` // provider filter States []model.DealState `json:"states"` // state filter - DealTypes []model.DealType `json:"dealTypes"` // deal type filter (market for f05, pdp for f41) + DealTypes []model.DealType `json:"dealTypes"` // deal type filter (market/f05_paid for f05, pdp for f41, ddo for allocations) } // ListHandler retrieves a list of deals from the database based on the specified filtering criteria in ListDealRequest. diff --git a/handler/deal/schedule/create.go b/handler/deal/schedule/create.go index 53ef7ed7..f9e04bdc 100644 --- a/handler/deal/schedule/create.go +++ b/handler/deal/schedule/create.go @@ -25,7 +25,7 @@ import ( type CreateRequest struct { Preparation string `json:"preparation" validation:"required"` // Preparation ID or name Provider string `json:"provider" validation:"required"` // Provider - DealType string `json:"dealType"` // Deal type: market (f05), pdp (f41), or ddo + DealType string `json:"dealType"` // Deal type: market (legacy f05), f05_paid (f05 with on-chain payments), pdp (f41), or ddo HTTPHeaders []string `json:"httpHeaders"` // http headers to be passed with the request (i.e. key=value) URLTemplate string `json:"urlTemplate"` // URL template with PIECE_CID placeholder for boost to fetch the CAR file, i.e. http://127.0.0.1/piece/{PIECE_CID}.car PricePerGBEpoch float64 `default:"0" json:"pricePerGbEpoch"` // Price in FIL per GiB per epoch diff --git a/handler/deal/schedule/create_test.go b/handler/deal/schedule/create_test.go index 97c5f89c..414f6a1a 100644 --- a/handler/deal/schedule/create_test.go +++ b/handler/deal/schedule/create_test.go @@ -246,6 +246,19 @@ func TestCreateHandler_PDPRejectsPreparationWithOversizedPiece(t *testing.T) { }) } +func TestCreateHandler_F05PaidAccepted(t *testing.T) { + testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + prep := createPrepWithWallet(t, db, "") + req := createRequest + req.Preparation = fmt.Sprintf("%d", prep.ID) + req.DealType = string(model.DealTypeF05Paid) + + schedule, err := Default.CreateHandler(ctx, db, getMockLotusClient(), req) + require.NoError(t, err) + require.Equal(t, model.DealTypeF05Paid, schedule.DealType) + }) +} + func TestCreateHandler_ProviderNormalizedToActorID(t *testing.T) { testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { createPrepWithWallet(t, db, "") diff --git a/handler/deal/schedule/update.go b/handler/deal/schedule/update.go index fc74c27d..4ca21f23 100644 --- a/handler/deal/schedule/update.go +++ b/handler/deal/schedule/update.go @@ -41,7 +41,7 @@ type UpdateRequest struct { //nolint:tagliatelle AllowedPieceCIDs []string `json:"allowedPieceCids"` // Allowed piece CIDs in this schedule Force *bool `json:"force"` // Force to send out deals regardless of replication restriction - DealType *string `json:"dealType"` // Deal type: market (f05) or pdp (f41) + DealType *string `json:"dealType"` // Deal type: market (legacy f05), f05_paid (f05 with on-chain payments), pdp (f41), or ddo } // UpdateHandler modifies an existing schedule record based on the provided update request. diff --git a/handler/deal/schedule/update_test.go b/handler/deal/schedule/update_test.go index d91c79d1..7c92167a 100644 --- a/handler/deal/schedule/update_test.go +++ b/handler/deal/schedule/update_test.go @@ -284,6 +284,19 @@ func TestUpdateHandler_DDORequiresURLTemplate(t *testing.T) { }) } +func TestUpdateHandler_F05PaidAccepted(t *testing.T) { + testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + err := db.Create(&model.Schedule{ + Preparation: &model.Preparation{}, + }).Error + require.NoError(t, err) + req := UpdateRequest{DealType: ptr.Of(string(model.DealTypeF05Paid))} + schedule, err := Default.UpdateHandler(ctx, db, 1, req) + require.NoError(t, err) + require.Equal(t, model.DealTypeF05Paid, schedule.DealType) + }) +} + func TestUpdateHandler_DDOClearURLTemplateRejected(t *testing.T) { testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { err := db.Create(&model.Schedule{ diff --git a/model/replication.go b/model/replication.go index 01e38baf..fa5563a8 100644 --- a/model/replication.go +++ b/model/replication.go @@ -10,7 +10,7 @@ type DealState string type ScheduleState string -// DealType represents the type of deal (legacy market vs PDP) +// DealType represents the type of deal (legacy market, paid f05, PDP, etc.) type DealType string const ( @@ -27,6 +27,8 @@ const ( const ( // DealTypeMarket represents legacy f05 market actor deals DealTypeMarket DealType = "market" + // DealTypeF05Paid represents f05 deals with on-chain payments + DealTypeF05Paid DealType = "f05_paid" // DealTypePDP represents f41 PDP (Proof of Data Possession) deals DealTypePDP DealType = "pdp" // DealTypeDDO represents DDO (Decentralized Data Onboarding) allocation deals @@ -68,12 +70,14 @@ var DealStates = []DealState{ var DealTypeStrings = []string{ string(DealTypeMarket), + string(DealTypeF05Paid), string(DealTypePDP), string(DealTypeDDO), } var DealTypes = []DealType{ DealTypeMarket, + DealTypeF05Paid, DealTypePDP, DealTypeDDO, } @@ -134,6 +138,11 @@ type Deal struct { ProofSetLive *bool `json:"proofSetLive,omitempty" table:"verbose"` // ProofSetLive indicates if the proof set is live (actively being challenged) NextChallengeEpoch *int32 `json:"nextChallengeEpoch,omitempty" table:"verbose"` // NextChallengeEpoch is the next epoch when a challenge proof is due + // F05 paid-deal fields (only populated for DealTypeF05Paid) + F05PaymentContract *string `json:"f05PaymentContract,omitempty" table:"verbose"` + F05PaymentTxHash *string `json:"f05PaymentTxHash,omitempty" table:"verbose"` + F05PaymentStatus *string `json:"f05PaymentStatus,omitempty" table:"verbose"` + // DDO-specific fields (only populated for DealTypeDDO) DDOAllocationID *uint64 `json:"ddoAllocationId,omitempty" table:"verbose"` DDORailID *uint64 `json:"ddoRailId,omitempty" table:"verbose"` diff --git a/service/dealpusher/dealpusher.go b/service/dealpusher/dealpusher.go index 6aba9d0c..c81c1714 100644 --- a/service/dealpusher/dealpusher.go +++ b/service/dealpusher/dealpusher.go @@ -45,6 +45,7 @@ type DealPusher struct { lotusClient jsonrpc.RPCClient // Lotus JSON-RPC client for chain queries dealMaker replication.DealMaker // Object responsible for making a deal in replication. pdpProofSetManager PDPProofSetManager // Optional PDP proof set lifecycle manager. + f05PaidDealManager F05PaidDealManager // Optional paid f05 deal lifecycle manager. pdpTxConfirmer PDPTransactionConfirmer // Optional PDP transaction confirmer. pdpSchedulingConfig PDPSchedulingConfig // PDP scheduling config for root batching and tx confirmation. ddoDealManager DDODealManager // Optional DDO deal lifecycle manager. @@ -256,6 +257,11 @@ func (d *DealPusher) runSchedule(ctx context.Context, schedule *model.Schedule) switch d.resolveScheduleDealType(schedule) { case model.DealTypePDP: return d.runPDPSchedule(ctx, schedule) + case model.DealTypeF05Paid: + if d.f05PaidDealManager == nil { + return model.ScheduleError, errors.New("f05 paid scheduling dependencies are not configured") + } + return d.f05PaidDealManager.RunSchedule(ctx, schedule) case model.DealTypeDDO: return d.runDDOSchedule(ctx, schedule) case model.DealTypeMarket: diff --git a/service/dealpusher/f05paid_api.go b/service/dealpusher/f05paid_api.go new file mode 100644 index 00000000..65c97250 --- /dev/null +++ b/service/dealpusher/f05paid_api.go @@ -0,0 +1,14 @@ +package dealpusher + +import ( + "context" + + "github.com/data-preservation-programs/singularity/model" +) + +// F05PaidDealManager owns the paid f05 schedule execution path. +// The first scaffold PR wires the type into Singularity; a later PR will +// provide the concrete implementation backed by the Singularity payments contract. +type F05PaidDealManager interface { + RunSchedule(ctx context.Context, schedule *model.Schedule) (model.ScheduleState, error) +} diff --git a/service/dealpusher/options.go b/service/dealpusher/options.go index 649dd2f6..a798dbd2 100644 --- a/service/dealpusher/options.go +++ b/service/dealpusher/options.go @@ -11,6 +11,12 @@ func WithPDPProofSetManager(manager PDPProofSetManager) Option { } } +func WithF05PaidDealManager(manager F05PaidDealManager) Option { + return func(d *DealPusher) { + d.f05PaidDealManager = manager + } +} + func WithPDPTransactionConfirmer(confirmer PDPTransactionConfirmer) Option { return func(d *DealPusher) { d.pdpTxConfirmer = confirmer diff --git a/service/dealpusher/pdp_wiring_test.go b/service/dealpusher/pdp_wiring_test.go index 8a20423d..e236e3f2 100644 --- a/service/dealpusher/pdp_wiring_test.go +++ b/service/dealpusher/pdp_wiring_test.go @@ -49,6 +49,17 @@ type txConfirmerMock struct { txHash string } +type f05PaidDealManagerMock struct { + schedule *model.Schedule + state model.ScheduleState + err error +} + +func (m *f05PaidDealManagerMock) RunSchedule(_ context.Context, schedule *model.Schedule) (model.ScheduleState, error) { + m.schedule = schedule + return m.state, m.err +} + func (m *txConfirmerMock) WaitForConfirmations(_ context.Context, txHash string, _ uint64, _ time.Duration) (*PDPTransactionReceipt, error) { m.txHash = txHash return &PDPTransactionReceipt{Hash: txHash}, nil @@ -85,6 +96,30 @@ func TestDealPusher_RunSchedule_PDPWithoutDependenciesReturnsConfiguredError(t * require.Contains(t, err.Error(), "pdp scheduling dependencies are not configured") } +func TestDealPusher_RunSchedule_F05PaidWithoutDependenciesReturnsConfiguredError(t *testing.T) { + d := &DealPusher{ + scheduleDealTypeResolver: func(_ *model.Schedule) model.DealType { + return model.DealTypeF05Paid + }, + } + + state, err := d.runSchedule(context.Background(), &model.Schedule{}) + require.Error(t, err) + require.Equal(t, model.ScheduleError, state) + require.Contains(t, err.Error(), "f05 paid scheduling dependencies are not configured") +} + +func TestDealPusher_RunSchedule_F05PaidUsesManager(t *testing.T) { + manager := &f05PaidDealManagerMock{state: model.ScheduleCompleted} + schedule := &model.Schedule{ID: 7, DealType: model.DealTypeF05Paid} + d := &DealPusher{f05PaidDealManager: manager} + + state, err := d.runSchedule(context.Background(), schedule) + require.NoError(t, err) + require.Equal(t, model.ScheduleCompleted, state) + require.Same(t, schedule, manager.schedule) +} + func TestDealPusher_RunSchedule_PDPWithDependenciesCreatesDealsAfterConfirmation(t *testing.T) { testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { clientSubaddr := make([]byte, 20) From 66fa55d4cd191761c574c5a37c360bcd8ac4ed44 Mon Sep 17 00:00:00 2001 From: anjor Date: Fri, 20 Mar 2026 14:06:10 +0000 Subject: [PATCH 2/3] Regenerate swagger and CLI docs --- .../swagger/models/deal_list_deal_request.go | 2 +- client/swagger/models/model_deal.go | 18 ++++++------- .../swagger/models/schedule_update_request.go | 2 +- docs/en/cli-reference/deal/list.md | 2 +- docs/en/cli-reference/deal/schedule/create.md | 2 +- docs/en/cli-reference/deal/schedule/update.md | 2 +- docs/swagger/docs.go | 24 ++++++++--------- docs/swagger/swagger.json | 26 +++++++++---------- docs/swagger/swagger.yaml | 23 +++++++++------- 9 files changed, 52 insertions(+), 49 deletions(-) diff --git a/client/swagger/models/deal_list_deal_request.go b/client/swagger/models/deal_list_deal_request.go index ef88917f..658d060d 100644 --- a/client/swagger/models/deal_list_deal_request.go +++ b/client/swagger/models/deal_list_deal_request.go @@ -20,7 +20,7 @@ import ( // swagger:model deal.ListDealRequest type DealListDealRequest struct { - // deal type filter (market for f05, pdp for f41) + // deal type filter (market/f05_paid for f05, pdp for f41, ddo for allocations) DealTypes []ModelDealType `json:"dealTypes"` // preparation ID or name filter diff --git a/client/swagger/models/model_deal.go b/client/swagger/models/model_deal.go index 20efa662..856f0cc8 100644 --- a/client/swagger/models/model_deal.go +++ b/client/swagger/models/model_deal.go @@ -25,15 +25,6 @@ type ModelDeal struct { // created at CreatedAt string `json:"createdAt,omitempty"` - // F05 paid-deal fields (only populated for DealTypeF05Paid) - F05PaymentContract string `json:"f05PaymentContract,omitempty"` - - // f05 payment status - F05PaymentStatus string `json:"f05PaymentStatus,omitempty"` - - // f05 payment transaction hash - F05PaymentTxHash string `json:"f05PaymentTxHash,omitempty"` - // DDO-specific fields (only populated for DealTypeDDO) DdoAllocationID int64 `json:"ddoAllocationId,omitempty"` @@ -55,6 +46,15 @@ type ModelDeal struct { // error message ErrorMessage string `json:"errorMessage,omitempty"` + // F05 paid-deal fields (only populated for DealTypeF05Paid) + F05PaymentContract string `json:"f05PaymentContract,omitempty"` + + // f05 payment status + F05PaymentStatus string `json:"f05PaymentStatus,omitempty"` + + // f05 payment tx hash + F05PaymentTxHash string `json:"f05PaymentTxHash,omitempty"` + // id ID int64 `json:"id,omitempty"` diff --git a/client/swagger/models/schedule_update_request.go b/client/swagger/models/schedule_update_request.go index b5b7fb6d..c5f63d60 100644 --- a/client/swagger/models/schedule_update_request.go +++ b/client/swagger/models/schedule_update_request.go @@ -20,7 +20,7 @@ type ScheduleUpdateRequest struct { // Allowed piece CIDs in this schedule AllowedPieceCids []string `json:"allowedPieceCids"` - // Deal type: market (f05) or pdp (f41) + // Deal type: market (legacy f05), f05_paid (f05 with on-chain payments), pdp (f41), or ddo DealType string `json:"dealType,omitempty"` // Duration in epoch or in duration format, i.e. 1500000, 2400h diff --git a/docs/en/cli-reference/deal/list.md b/docs/en/cli-reference/deal/list.md index 53a6f999..ecf94a0b 100644 --- a/docs/en/cli-reference/deal/list.md +++ b/docs/en/cli-reference/deal/list.md @@ -14,7 +14,7 @@ OPTIONS: --schedule value [ --schedule value ] Filter deals by schedule --provider value [ --provider value ] Filter deals by provider --state value [ --state value ] Filter deals by state: proposed, published, active, expired, proposal_expired, slashed - --deal-type value [ --deal-type value ] Filter deals by type: market (legacy f05), pdp (f41 PDP deals) + --deal-type value [ --deal-type value ] Filter deals by type: market, f05_paid, pdp, ddo --help, -h show help ``` {% endcode %} diff --git a/docs/en/cli-reference/deal/schedule/create.md b/docs/en/cli-reference/deal/schedule/create.md index f96f8a65..bc39659c 100644 --- a/docs/en/cli-reference/deal/schedule/create.md +++ b/docs/en/cli-reference/deal/schedule/create.md @@ -52,7 +52,7 @@ OPTIONS: Deal Proposal - --deal-type value Deal type: market (legacy f05), pdp (f41), or ddo (DDO allocations) (default: "market") + --deal-type value Deal type: market (legacy f05), f05_paid (f05 with on-chain payments), pdp (f41), or ddo (DDO allocations) (default: "market") --duration value, -d value Duration in epoch or in duration format, i.e. 1500000, 2400h (default: 12840h[535 days]) --keep-unsealed Whether to keep unsealed copy (default: true) --price-per-deal value Price in FIL per deal (default: 0) diff --git a/docs/en/cli-reference/deal/schedule/update.md b/docs/en/cli-reference/deal/schedule/update.md index aefac99c..01f1064d 100644 --- a/docs/en/cli-reference/deal/schedule/update.md +++ b/docs/en/cli-reference/deal/schedule/update.md @@ -50,7 +50,7 @@ OPTIONS: Deal Proposal - --deal-type value Deal type: market (legacy f05) or pdp (f41) + --deal-type value Deal type: market (legacy f05), f05_paid (f05 with on-chain payments), pdp (f41), or ddo (DDO allocations) --duration value, -d value Duration in epoch or in duration format, i.e. 1500000, 2400h --keep-unsealed Whether to keep unsealed copy (default: true) --price-per-deal value Price in FIL per deal (default: 0) diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 91e464dc..21ad78bc 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -6163,7 +6163,7 @@ const docTemplate = `{ "type": "object", "properties": { "dealTypes": { - "description": "deal type filter (market for f05, pdp for f41)", + "description": "deal type filter (market/f05_paid for f05, pdp for f41, ddo for allocations)", "type": "array", "items": { "$ref": "#/definitions/model.DealType" @@ -6493,16 +6493,6 @@ const docTemplate = `{ "createdAt": { "type": "string" }, - "f05PaymentContract": { - "description": "F05 paid-deal fields (only populated for DealTypeF05Paid)", - "type": "string" - }, - "f05PaymentStatus": { - "type": "string" - }, - "f05PaymentTxHash": { - "type": "string" - }, "ddoAllocationId": { "description": "DDO-specific fields (only populated for DealTypeDDO)", "type": "integer" @@ -6525,6 +6515,16 @@ const docTemplate = `{ "errorMessage": { "type": "string" }, + "f05PaymentContract": { + "description": "F05 paid-deal fields (only populated for DealTypeF05Paid)", + "type": "string" + }, + "f05PaymentStatus": { + "type": "string" + }, + "f05PaymentTxHash": { + "type": "string" + }, "id": { "type": "integer" }, @@ -7121,7 +7121,7 @@ const docTemplate = `{ } }, "dealType": { - "description": "Deal type: market (f05) or pdp (f41)", + "description": "Deal type: market (legacy f05), f05_paid (f05 with on-chain payments), pdp (f41), or ddo", "type": "string" }, "duration": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index c9c9d1bd..486bf63f 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -6156,7 +6156,7 @@ "type": "object", "properties": { "dealTypes": { - "description": "deal type filter (market for f05, pdp for f41)", + "description": "deal type filter (market/f05_paid for f05, pdp for f41, ddo for allocations)", "type": "array", "items": { "$ref": "#/definitions/model.DealType" @@ -6486,16 +6486,6 @@ "createdAt": { "type": "string" }, - "f05PaymentContract": { - "description": "F05 paid-deal fields (only populated for DealTypeF05Paid)", - "type": "string" - }, - "f05PaymentStatus": { - "type": "string" - }, - "f05PaymentTxHash": { - "type": "string" - }, "ddoAllocationId": { "description": "DDO-specific fields (only populated for DealTypeDDO)", "type": "integer" @@ -6518,6 +6508,16 @@ "errorMessage": { "type": "string" }, + "f05PaymentContract": { + "description": "F05 paid-deal fields (only populated for DealTypeF05Paid)", + "type": "string" + }, + "f05PaymentStatus": { + "type": "string" + }, + "f05PaymentTxHash": { + "type": "string" + }, "id": { "type": "integer" }, @@ -7114,7 +7114,7 @@ } }, "dealType": { - "description": "Deal type: market (f05) or pdp (f41)", + "description": "Deal type: market (legacy f05), f05_paid (f05 with on-chain payments), pdp (f41), or ddo", "type": "string" }, "duration": { @@ -20392,4 +20392,4 @@ "description": "OpenAPI", "url": "https://swagger.io/resources/open-api/" } -} +} \ No newline at end of file diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index ba0bdb7b..dd0edcc9 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -152,7 +152,8 @@ definitions: deal.ListDealRequest: properties: dealTypes: - description: deal type filter (market for f05, pdp for f41) + description: deal type filter (market/f05_paid for f05, pdp for f41, ddo for + allocations) items: $ref: '#/definitions/model.DealType' type: array @@ -395,13 +396,6 @@ definitions: type: string createdAt: type: string - f05PaymentContract: - description: F05 paid-deal fields (only populated for DealTypeF05Paid) - type: string - f05PaymentStatus: - type: string - f05PaymentTxHash: - type: string ddoAllocationId: description: DDO-specific fields (only populated for DealTypeDDO) type: integer @@ -417,6 +411,13 @@ definitions: type: integer errorMessage: type: string + f05PaymentContract: + description: F05 paid-deal fields (only populated for DealTypeF05Paid) + type: string + f05PaymentStatus: + type: string + f05PaymentTxHash: + type: string id: type: integer label: @@ -763,7 +764,8 @@ definitions: type: string type: array dealType: - description: 'Deal type: market (legacy f05), f05_paid (f05 with on-chain payments), pdp (f41), or ddo' + description: 'Deal type: market (legacy f05), f05_paid (f05 with on-chain + payments), pdp (f41), or ddo' type: string duration: default: 12840h @@ -852,7 +854,8 @@ definitions: type: string type: array dealType: - description: 'Deal type: market (f05) or pdp (f41)' + description: 'Deal type: market (legacy f05), f05_paid (f05 with on-chain + payments), pdp (f41), or ddo' type: string duration: default: 12840h From 433bdb3b843bbb17761596a020896b50f7702cc5 Mon Sep 17 00:00:00 2001 From: anjor Date: Thu, 26 Mar 2026 18:08:07 +0000 Subject: [PATCH 3/3] Add experimental paid f05 preflight adapter --- cmd/run/dealpusher.go | 56 +++++- docs/en/cli-reference/run/deal-pusher.md | 6 +- service/dealpusher/f05paid_api.go | 59 +++++++ service/dealpusher/f05paid_api_test.go | 34 ++++ service/dealpusher/f05paid_onchain.go | 193 +++++++++++++++++++++ service/dealpusher/f05paid_onchain_test.go | 139 +++++++++++++++ 6 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 service/dealpusher/f05paid_api_test.go create mode 100644 service/dealpusher/f05paid_onchain.go create mode 100644 service/dealpusher/f05paid_onchain_test.go diff --git a/cmd/run/dealpusher.go b/cmd/run/dealpusher.go index b78a25ff..e08399b4 100644 --- a/cmd/run/dealpusher.go +++ b/cmd/run/dealpusher.go @@ -4,9 +4,11 @@ import ( "time" "github.com/cockroachdb/errors" + "github.com/data-preservation-programs/singularity/handler/wallet" "github.com/data-preservation-programs/singularity/service" "github.com/data-preservation-programs/singularity/service/dealpusher" "github.com/data-preservation-programs/singularity/service/epochutil" + "github.com/data-preservation-programs/singularity/util/keystore" "github.com/urfave/cli/v2" ) @@ -49,9 +51,29 @@ var DealPusherCmd = &cli.Command{ }, &cli.StringFlag{ Name: "eth-rpc", - Usage: "Ethereum RPC endpoint for FEVM (required to execute PDP and DDO schedules on-chain)", + Usage: "Ethereum RPC endpoint for FEVM (required to execute PDP, DDO, and experimental paid f05 schedules on-chain)", EnvVars: []string{"ETH_RPC_URL"}, }, + &cli.BoolFlag{ + Name: "f05-experimental", + Usage: "Enable experimental paid f05 registry and FIL-balance preflight", + }, + &cli.StringFlag{ + Name: "f05-min-wallet-balance", + Usage: "Minimum FIL wallet balance required before attempting paid f05 schedules", + Value: "0", + EnvVars: []string{"F05_MIN_WALLET_BALANCE"}, + }, + &cli.StringFlag{ + Name: "f05-sp-registry-contract", + Usage: "SP Registry contract address override for experimental paid f05 scheduling", + EnvVars: []string{"F05_SP_REGISTRY_CONTRACT_ADDRESS"}, + }, + &cli.StringFlag{ + Name: "f05-payments-contract", + Usage: "Payments contract address override for experimental paid f05 scheduling", + EnvVars: []string{"F05_PAYMENTS_CONTRACT_ADDRESS"}, + }, &cli.StringFlag{ Name: "ddo-contract", Usage: "DDO Diamond proxy contract address", @@ -137,6 +159,38 @@ var DealPusherCmd = &cli.Command{ ) } + if c.Bool("f05-experimental") { + rpcURL := c.String("eth-rpc") + if rpcURL == "" { + return errors.New("--eth-rpc is required when --f05-experimental is set") + } + + minWalletBalance, err := dealpusher.ParseFILAmount(c.String("f05-min-wallet-balance")) + if err != nil { + return errors.Wrap(err, "invalid --f05-min-wallet-balance") + } + f05Cfg := dealpusher.F05PaidSchedulingConfig{ + MinWalletBalanceAttoFIL: minWalletBalance, + SPRegistryAddress: c.String("f05-sp-registry-contract"), + PaymentsAddress: c.String("f05-payments-contract"), + } + if err := f05Cfg.Validate(); err != nil { + return errors.WithStack(err) + } + + ks, err := keystore.NewLocalKeyStore(wallet.GetKeystoreDir()) + if err != nil { + return errors.Wrap(err, "failed to initialize keystore for paid f05 scheduling") + } + f05Adapter, err := dealpusher.NewOnChainF05Paid(c.Context, db, ks, rpcURL, f05Cfg) + if err != nil { + return errors.Wrap(err, "failed to initialize experimental paid f05 adapter") + } + defer f05Adapter.Close() + + opts = append(opts, dealpusher.WithF05PaidDealManager(f05Adapter)) + } + if ddoContract := c.String("ddo-contract"); ddoContract != "" { ddoCfg := dealpusher.DDOSchedulingConfig{ BatchSize: c.Int("ddo-batch-size"), diff --git a/docs/en/cli-reference/run/deal-pusher.md b/docs/en/cli-reference/run/deal-pusher.md index 05e03498..2c548dc2 100644 --- a/docs/en/cli-reference/run/deal-pusher.md +++ b/docs/en/cli-reference/run/deal-pusher.md @@ -16,7 +16,11 @@ OPTIONS: --pdp-max-pieces-per-proofset value Maximum pieces per proof set before handing off to the storage provider (default: 1024) --pdp-confirmation-depth value Number of block confirmations required for PDP transactions (default: 5) --pdp-poll-interval value Polling interval for PDP transaction confirmation checks (default: 30s) - --eth-rpc value Ethereum RPC endpoint for FEVM (required to execute PDP and DDO schedules on-chain) [$ETH_RPC_URL] + --eth-rpc value Ethereum RPC endpoint for FEVM (required to execute PDP, DDO, and experimental paid f05 schedules on-chain) [$ETH_RPC_URL] + --f05-experimental Enable experimental paid f05 registry and FIL-balance preflight (default: false) + --f05-min-wallet-balance value Minimum FIL wallet balance required before attempting paid f05 schedules (default: "0") [$F05_MIN_WALLET_BALANCE] + --f05-sp-registry-contract value SP Registry contract address override for experimental paid f05 scheduling [$F05_SP_REGISTRY_CONTRACT_ADDRESS] + --f05-payments-contract value Payments contract address override for experimental paid f05 scheduling [$F05_PAYMENTS_CONTRACT_ADDRESS] --ddo-contract value DDO Diamond proxy contract address [$DDO_CONTRACT_ADDRESS] --ddo-payments-contract value DDO Payments proxy contract address [$DDO_PAYMENTS_CONTRACT_ADDRESS] --ddo-payment-token value ERC20 payment token address (e.g. USDFC) [$DDO_PAYMENT_TOKEN] diff --git a/service/dealpusher/f05paid_api.go b/service/dealpusher/f05paid_api.go index 65c97250..03e9d382 100644 --- a/service/dealpusher/f05paid_api.go +++ b/service/dealpusher/f05paid_api.go @@ -2,6 +2,9 @@ package dealpusher import ( "context" + "errors" + "math/big" + "strings" "github.com/data-preservation-programs/singularity/model" ) @@ -12,3 +15,59 @@ import ( type F05PaidDealManager interface { RunSchedule(ctx context.Context, schedule *model.Schedule) (model.ScheduleState, error) } + +// F05PaidSchedulingConfig holds experimental paid-f05 scheduling knobs. +type F05PaidSchedulingConfig struct { + MinWalletBalanceAttoFIL *big.Int + SPRegistryAddress string + PaymentsAddress string +} + +func defaultF05PaidSchedulingConfig() F05PaidSchedulingConfig { + return F05PaidSchedulingConfig{ + MinWalletBalanceAttoFIL: big.NewInt(0), + } +} + +// Validate validates paid-f05 scheduling configuration. +func (c F05PaidSchedulingConfig) Validate() error { + if c.MinWalletBalanceAttoFIL == nil { + return errors.New("f05 minimum wallet balance must be set") + } + if c.MinWalletBalanceAttoFIL.Sign() < 0 { + return errors.New("f05 minimum wallet balance cannot be negative") + } + return nil +} + +var attoFIL = new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil) + +// ParseFILAmount converts a FIL decimal string into attoFIL. +func ParseFILAmount(value string) (*big.Int, error) { + value = strings.TrimSpace(value) + if value == "" { + return big.NewInt(0), nil + } + + r, ok := new(big.Rat).SetString(value) + if !ok { + return nil, errors.New("invalid FIL amount") + } + if r.Sign() < 0 { + return nil, errors.New("FIL amount cannot be negative") + } + + r.Mul(r, new(big.Rat).SetInt(attoFIL)) + if !r.IsInt() { + return nil, errors.New("FIL amount must have at most 18 decimal places") + } + + return new(big.Int).Set(r.Num()), nil +} + +func formatAttoFIL(value *big.Int) string { + if value == nil { + return "0" + } + return new(big.Rat).SetFrac(value, attoFIL).FloatString(18) +} diff --git a/service/dealpusher/f05paid_api_test.go b/service/dealpusher/f05paid_api_test.go new file mode 100644 index 00000000..b7b225cb --- /dev/null +++ b/service/dealpusher/f05paid_api_test.go @@ -0,0 +1,34 @@ +package dealpusher + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestF05PaidSchedulingConfigValidate(t *testing.T) { + require.NoError(t, defaultF05PaidSchedulingConfig().Validate()) + + err := (F05PaidSchedulingConfig{}).Validate() + require.ErrorContains(t, err, "must be set") + + err = (F05PaidSchedulingConfig{MinWalletBalanceAttoFIL: big.NewInt(-1)}).Validate() + require.ErrorContains(t, err, "cannot be negative") +} + +func TestParseFILAmount(t *testing.T) { + value, err := ParseFILAmount("1.5") + require.NoError(t, err) + require.Equal(t, "1500000000000000000", value.String()) + + value, err = ParseFILAmount("") + require.NoError(t, err) + require.Zero(t, value.Sign()) + + _, err = ParseFILAmount("0.1234567890123456789") + require.ErrorContains(t, err, "at most 18 decimal places") + + _, err = ParseFILAmount("-1") + require.ErrorContains(t, err, "cannot be negative") +} diff --git a/service/dealpusher/f05paid_onchain.go b/service/dealpusher/f05paid_onchain.go new file mode 100644 index 00000000..c04385a4 --- /dev/null +++ b/service/dealpusher/f05paid_onchain.go @@ -0,0 +1,193 @@ +package dealpusher + +import ( + "context" + "fmt" + "math/big" + + synapse "github.com/data-preservation-programs/go-synapse" + synpayments "github.com/data-preservation-programs/go-synapse/payments" + "github.com/data-preservation-programs/go-synapse/spregistry" + "github.com/data-preservation-programs/singularity/model" + "github.com/data-preservation-programs/singularity/util/keystore" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "gorm.io/gorm" +) + +type f05WalletBalanceClient interface { + BalanceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (*big.Int, error) +} + +type f05ProviderRegistry interface { + GetProvider(ctx context.Context, providerID int) (*spregistry.ProviderInfo, error) +} + +// OnChainF05Paid provides experimental registry and wallet-balance preflight +// for paid-f05 schedules while the final execution path is still being built. +type OnChainF05Paid struct { + dbNoContext *gorm.DB + keyStore keystore.KeyStore + ethClient *ethclient.Client + balanceClient f05WalletBalanceClient + providerRegistry f05ProviderRegistry + cfg F05PaidSchedulingConfig + network synapse.Network + chainID *big.Int + spRegistryAddr common.Address + paymentsAddr common.Address +} + +func NewOnChainF05Paid( + ctx context.Context, + db *gorm.DB, + keyStore keystore.KeyStore, + rpcURL string, + cfg F05PaidSchedulingConfig, +) (*OnChainF05Paid, error) { + if rpcURL == "" { + return nil, fmt.Errorf("eth rpc URL is required") + } + if keyStore == nil { + return nil, fmt.Errorf("keystore is required") + } + if err := cfg.Validate(); err != nil { + return nil, err + } + + ethClient, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + return nil, fmt.Errorf("failed to connect to FEVM RPC: %w", err) + } + + network, chainIDInt64, err := synapse.DetectNetwork(ctx, ethClient) + if err != nil { + ethClient.Close() + return nil, fmt.Errorf("failed to detect FEVM network: %w", err) + } + + registryAddr := synapse.GetSPRegistryAddress(network) + if cfg.SPRegistryAddress != "" { + registryAddr, err = parseHexAddress(cfg.SPRegistryAddress) + if err != nil { + ethClient.Close() + return nil, fmt.Errorf("invalid f05 SP registry contract address: %w", err) + } + } + if registryAddr == (common.Address{}) { + ethClient.Close() + return nil, fmt.Errorf("no SP registry contract configured for network %s", network) + } + + paymentsAddr, ok := synpayments.PaymentsAddresses[chainIDInt64] + if cfg.PaymentsAddress != "" { + paymentsAddr, err = parseHexAddress(cfg.PaymentsAddress) + if err != nil { + ethClient.Close() + return nil, fmt.Errorf("invalid f05 payments contract address: %w", err) + } + ok = true + } + if !ok || paymentsAddr == (common.Address{}) { + ethClient.Close() + return nil, fmt.Errorf("no payments contract configured for chain ID %d", chainIDInt64) + } + + providerRegistry, err := spregistry.NewService(ethClient, registryAddr, nil, big.NewInt(chainIDInt64)) + if err != nil { + ethClient.Close() + return nil, fmt.Errorf("failed to initialize SP registry client: %w", err) + } + + Logger.Infow("initialized experimental paid f05 adapter", + "network", network, + "chainId", chainIDInt64, + "spRegistry", registryAddr.Hex(), + "payments", paymentsAddr.Hex(), + ) + + return &OnChainF05Paid{ + dbNoContext: db, + keyStore: keyStore, + ethClient: ethClient, + balanceClient: ethClient, + providerRegistry: providerRegistry, + cfg: cfg, + network: network, + chainID: big.NewInt(chainIDInt64), + spRegistryAddr: registryAddr, + paymentsAddr: paymentsAddr, + }, nil +} + +func (o *OnChainF05Paid) Close() error { + if o.ethClient != nil { + o.ethClient.Close() + } + return nil +} + +func (o *OnChainF05Paid) RunSchedule(ctx context.Context, schedule *model.Schedule) (model.ScheduleState, error) { + if err := o.cfg.Validate(); err != nil { + return model.ScheduleError, fmt.Errorf("invalid paid f05 scheduling configuration: %w", err) + } + if schedule == nil { + return model.ScheduleError, fmt.Errorf("schedule is required") + } + if schedule.Preparation == nil || schedule.Preparation.Wallet == nil { + return model.ScheduleError, fmt.Errorf("schedule has no wallet configured") + } + + walletObj := *schedule.Preparation.Wallet + evmSigner, err := keystore.EVMSigner(o.keyStore, walletObj) + if err != nil { + return model.ScheduleError, fmt.Errorf("failed to load EVM signer for wallet: %w", err) + } + + providerActorID, err := parseProviderActorID(schedule.Provider) + if err != nil { + return model.ScheduleError, fmt.Errorf("failed to parse provider actor ID: %w", err) + } + + provider, err := o.providerRegistry.GetProvider(ctx, int(providerActorID)) + if err != nil { + return model.ScheduleError, fmt.Errorf("failed to query provider %s in SP Registry: %w", schedule.Provider, err) + } + if provider == nil { + return model.ScheduleError, fmt.Errorf("provider %s is not registered in SP Registry", schedule.Provider) + } + if !provider.Active { + return model.ScheduleError, fmt.Errorf("provider %s is not active in SP Registry", schedule.Provider) + } + if provider.Payee == (common.Address{}) { + return model.ScheduleError, fmt.Errorf("provider %s has no payee configured in SP Registry", schedule.Provider) + } + + walletBalance, err := o.balanceClient.BalanceAt(ctx, evmSigner.EVMAddress(), nil) + if err != nil { + return model.ScheduleError, fmt.Errorf("failed to query FIL balance for wallet %s: %w", walletObj.Address, err) + } + if walletBalance.Sign() <= 0 { + return model.ScheduleError, fmt.Errorf("wallet %s has no FIL balance available for paid f05 gas", walletObj.Address) + } + if walletBalance.Cmp(o.cfg.MinWalletBalanceAttoFIL) < 0 { + return model.ScheduleError, fmt.Errorf( + "wallet %s FIL balance %s is below the configured minimum %s", + walletObj.Address, + formatAttoFIL(walletBalance), + formatAttoFIL(o.cfg.MinWalletBalanceAttoFIL), + ) + } + + serviceURL := "" + if product, ok := provider.Products["PDP"]; ok && product != nil && product.Data != nil { + serviceURL = product.Data.ServiceURL + } + + return model.ScheduleError, fmt.Errorf( + "paid f05 schedule passed provider and FIL balance preflight (payee=%s, serviceURL=%s, payments=%s), but execution is not implemented yet", + provider.Payee.Hex(), + serviceURL, + o.paymentsAddr.Hex(), + ) +} diff --git a/service/dealpusher/f05paid_onchain_test.go b/service/dealpusher/f05paid_onchain_test.go new file mode 100644 index 00000000..11a3e7bb --- /dev/null +++ b/service/dealpusher/f05paid_onchain_test.go @@ -0,0 +1,139 @@ +package dealpusher + +import ( + "context" + "math/big" + "testing" + + "github.com/data-preservation-programs/go-synapse/spregistry" + "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/stretchr/testify/require" +) + +type balanceClientMock struct { + balance *big.Int + err error +} + +func (m *balanceClientMock) BalanceAt(context.Context, common.Address, *big.Int) (*big.Int, error) { + if m.err != nil { + return nil, m.err + } + return new(big.Int).Set(m.balance), nil +} + +type providerRegistryMock struct { + provider *spregistry.ProviderInfo + err error +} + +func (m *providerRegistryMock) GetProvider(context.Context, int) (*spregistry.ProviderInfo, error) { + return m.provider, m.err +} + +func newF05PaidTestSchedule(t *testing.T) (*keystore.LocalKeyStore, *model.Schedule) { + t.Helper() + + ks, err := keystore.NewLocalKeyStore(t.TempDir()) + require.NoError(t, err) + + keyPath, addr, err := ks.Put(testutil.TestPrivateKeyHex) + require.NoError(t, err) + + wallet := &model.Wallet{ + ID: 1, + Address: addr.String(), + KeyPath: keyPath, + KeyStore: "local", + } + + return ks, &model.Schedule{ + ID: 1, + Provider: "f01000", + Preparation: &model.Preparation{ + Wallet: wallet, + }, + } +} + +func TestOnChainF05PaidRunScheduleRequiresWallet(t *testing.T) { + manager := &OnChainF05Paid{cfg: defaultF05PaidSchedulingConfig()} + state, err := manager.RunSchedule(context.Background(), &model.Schedule{}) + require.ErrorContains(t, err, "no wallet configured") + require.Equal(t, model.ScheduleError, state) +} + +func TestOnChainF05PaidRunScheduleRejectsMissingProvider(t *testing.T) { + ks, schedule := newF05PaidTestSchedule(t) + manager := &OnChainF05Paid{ + keyStore: ks, + cfg: defaultF05PaidSchedulingConfig(), + balanceClient: &balanceClientMock{balance: big.NewInt(1)}, + providerRegistry: &providerRegistryMock{}, + } + + state, err := manager.RunSchedule(context.Background(), schedule) + require.ErrorContains(t, err, "not registered in SP Registry") + require.Equal(t, model.ScheduleError, state) +} + +func TestOnChainF05PaidRunScheduleRejectsInactiveProvider(t *testing.T) { + ks, schedule := newF05PaidTestSchedule(t) + manager := &OnChainF05Paid{ + keyStore: ks, + cfg: defaultF05PaidSchedulingConfig(), + balanceClient: &balanceClientMock{balance: big.NewInt(1)}, + providerRegistry: &providerRegistryMock{provider: &spregistry.ProviderInfo{ + Active: false, + }}, + } + + state, err := manager.RunSchedule(context.Background(), schedule) + require.ErrorContains(t, err, "not active in SP Registry") + require.Equal(t, model.ScheduleError, state) +} + +func TestOnChainF05PaidRunScheduleRejectsLowBalance(t *testing.T) { + ks, schedule := newF05PaidTestSchedule(t) + manager := &OnChainF05Paid{ + keyStore: ks, + cfg: F05PaidSchedulingConfig{ + MinWalletBalanceAttoFIL: big.NewInt(2), + }, + balanceClient: &balanceClientMock{balance: big.NewInt(1)}, + providerRegistry: &providerRegistryMock{provider: &spregistry.ProviderInfo{ + Active: true, + Payee: common.HexToAddress("0x00000000000000000000000000000000000000aa"), + }}, + } + + state, err := manager.RunSchedule(context.Background(), schedule) + require.ErrorContains(t, err, "below the configured minimum") + require.Equal(t, model.ScheduleError, state) +} + +func TestOnChainF05PaidRunScheduleReturnsNotImplementedAfterPreflight(t *testing.T) { + ks, schedule := newF05PaidTestSchedule(t) + manager := &OnChainF05Paid{ + keyStore: ks, + cfg: defaultF05PaidSchedulingConfig(), + balanceClient: &balanceClientMock{balance: big.NewInt(1)}, + paymentsAddr: common.HexToAddress("0x00000000000000000000000000000000000000bb"), + providerRegistry: &providerRegistryMock{provider: &spregistry.ProviderInfo{ + Active: true, + Payee: common.HexToAddress("0x00000000000000000000000000000000000000aa"), + Products: map[string]*spregistry.ServiceProduct{ + "PDP": { + Data: &spregistry.PDPOffering{ServiceURL: "https://provider.example"}, + }, + }, + }}, + } + + state, err := manager.RunSchedule(context.Background(), schedule) + require.ErrorContains(t, err, "execution is not implemented yet") + require.Equal(t, model.ScheduleError, state) +}