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 71b6e8b3..856f0cc8 100644 --- a/client/swagger/models/model_deal.go +++ b/client/swagger/models/model_deal.go @@ -46,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/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/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/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/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/cmd/run/dealtracker.go b/cmd/run/dealtracker.go index f3e49640..78a98f34 100644 --- a/cmd/run/dealtracker.go +++ b/cmd/run/dealtracker.go @@ -35,7 +35,7 @@ var DealTrackerCmd = &cli.Command{ }, &cli.StringFlag{ Name: "eth-rpc", - Usage: "Ethereum RPC endpoint for FEVM (required for DDO allocation tracking)", + Usage: "Ethereum RPC endpoint for FEVM (required for DDO allocation tracking and paid f05 transaction receipt tracking)", EnvVars: []string{"ETH_RPC_URL"}, }, &cli.StringFlag{ @@ -59,6 +59,15 @@ var DealTrackerCmd = &cli.Command{ } var opts []dealtracker.DealTrackerOption + if rpcURL := c.String("eth-rpc"); rpcURL != "" { + f05Client, err := dealtracker.NewF05PaymentTrackingClient(c.Context, rpcURL) + if err != nil { + return errors.Wrap(err, "failed to initialize paid f05 tracking client") + } + defer f05Client.Close() + opts = append(opts, dealtracker.WithF05PaymentTracker(f05Client)) + } + if ddoContract := c.String("ddo-contract"); ddoContract != "" { rpcURL := c.String("eth-rpc") if rpcURL == "" { 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/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/docs/en/cli-reference/run/deal-tracker.md b/docs/en/cli-reference/run/deal-tracker.md index f7dfafb4..9e1ae3e6 100644 --- a/docs/en/cli-reference/run/deal-tracker.md +++ b/docs/en/cli-reference/run/deal-tracker.md @@ -13,7 +13,7 @@ OPTIONS: --market-deal-url value, -m value The URL for ZST compressed state market deals json. Set to empty to use Lotus API. (default: "https://marketdeals.s3.amazonaws.com/StateMarketDeals.json.zst") [$MARKET_DEAL_URL] --interval value, -i value How often to check for new deals (default: 1h0m0s) --once Run once and exit (default: false) - --eth-rpc value Ethereum RPC endpoint for FEVM (required for DDO allocation tracking) [$ETH_RPC_URL] + --eth-rpc value Ethereum RPC endpoint for FEVM (required for DDO allocation tracking and paid f05 transaction receipt tracking) [$ETH_RPC_URL] --ddo-contract value DDO Diamond proxy contract address [$DDO_CONTRACT_ADDRESS] --help, -h show help ``` diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 3cddffd7..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" @@ -6515,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" }, @@ -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": { @@ -7109,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 adb43358..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" @@ -6508,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" }, @@ -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": { @@ -7102,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": { diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index ba513bd8..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 @@ -410,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: @@ -478,11 +486,13 @@ definitions: model.DealType: enum: - market + - f05_paid - pdp - ddo type: string x-enum-varnames: - DealTypeMarket + - DealTypeF05Paid - DealTypePDP - DealTypeDDO model.File: @@ -754,7 +764,8 @@ 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 @@ -843,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 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..03e9d382 --- /dev/null +++ b/service/dealpusher/f05paid_api.go @@ -0,0 +1,73 @@ +package dealpusher + +import ( + "context" + "errors" + "math/big" + "strings" + + "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) +} + +// 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..79d39634 --- /dev/null +++ b/service/dealpusher/f05paid_onchain.go @@ -0,0 +1,272 @@ +package dealpusher + +import ( + "context" + "fmt" + "math/big" + + synapse "github.com/data-preservation-programs/go-synapse" + "github.com/data-preservation-programs/go-synapse/contracts" + 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) +} + +type f05PaymentsContract interface { + GetAccountInfoIfSettled(ctx context.Context, token, owner common.Address) (fundedUntilEpoch, currentFunds, availableFunds, currentLockupRate *big.Int, err error) + GetOperatorApproval(ctx context.Context, token, client, operator common.Address) (isApproved bool, rateAllowance, lockupAllowance, rateUsed, lockupUsed, maxLockupPeriod *big.Int, err 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 + paymentsContract f05PaymentsContract + 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) + } + + paymentsContract, err := contracts.NewPaymentsContract(paymentsAddr, ethClient) + if err != nil { + ethClient.Close() + return nil, fmt.Errorf("failed to initialize payments contract client: %w", err) + } + + 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, + paymentsContract: paymentsContract, + 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.ServiceProvider == (common.Address{}) { + return model.ScheduleError, fmt.Errorf("provider %s has no service provider address configured 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) + } + + product, ok := provider.Products["PDP"] + if !ok || product == nil { + return model.ScheduleError, fmt.Errorf("provider %s has no PDP product configured in SP Registry", schedule.Provider) + } + if !product.IsActive { + return model.ScheduleError, fmt.Errorf("provider %s PDP product is not active in SP Registry", schedule.Provider) + } + if product.Data == nil { + return model.ScheduleError, fmt.Errorf("provider %s PDP product is missing capability data in SP Registry", schedule.Provider) + } + if product.Data.ServiceURL == "" { + return model.ScheduleError, fmt.Errorf("provider %s PDP product has no service URL configured in SP Registry", schedule.Provider) + } + if product.Data.PaymentTokenAddress == (common.Address{}) { + return model.ScheduleError, fmt.Errorf("provider %s PDP product has no payment token 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), + ) + } + + if o.paymentsContract == nil { + return model.ScheduleError, fmt.Errorf("payments contract client is not configured") + } + + _, _, availableFunds, _, err := o.paymentsContract.GetAccountInfoIfSettled(ctx, product.Data.PaymentTokenAddress, evmSigner.EVMAddress()) + if err != nil { + return model.ScheduleError, fmt.Errorf("failed to query payments account for wallet %s: %w", walletObj.Address, err) + } + if availableFunds == nil || availableFunds.Sign() <= 0 { + return model.ScheduleError, fmt.Errorf( + "wallet %s has no available funds in payments contract %s for token %s", + walletObj.Address, + o.paymentsAddr.Hex(), + product.Data.PaymentTokenAddress.Hex(), + ) + } + + isApproved, rateAllowance, lockupAllowance, rateUsed, lockupUsed, maxLockupPeriod, err := o.paymentsContract.GetOperatorApproval( + ctx, + product.Data.PaymentTokenAddress, + evmSigner.EVMAddress(), + provider.ServiceProvider, + ) + if err != nil { + return model.ScheduleError, fmt.Errorf( + "failed to query operator approval for provider %s service %s: %w", + schedule.Provider, + provider.ServiceProvider.Hex(), + err, + ) + } + if !isApproved { + return model.ScheduleError, fmt.Errorf( + "wallet %s has not approved provider %s service %s on payments contract %s for token %s", + walletObj.Address, + schedule.Provider, + provider.ServiceProvider.Hex(), + o.paymentsAddr.Hex(), + product.Data.PaymentTokenAddress.Hex(), + ) + } + + return model.ScheduleError, fmt.Errorf( + "paid f05 schedule passed provider, wallet, and payments preflight (payee=%s, service=%s, serviceURL=%s, token=%s, availableFunds=%s, rateAllowance=%s, rateUsed=%s, lockupAllowance=%s, lockupUsed=%s, maxLockupPeriod=%s, payments=%s), but execution is not implemented yet", + provider.Payee.Hex(), + provider.ServiceProvider.Hex(), + product.Data.ServiceURL, + product.Data.PaymentTokenAddress.Hex(), + availableFunds.String(), + rateAllowance.String(), + rateUsed.String(), + lockupAllowance.String(), + lockupUsed.String(), + maxLockupPeriod.String(), + 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..046b5863 --- /dev/null +++ b/service/dealpusher/f05paid_onchain_test.go @@ -0,0 +1,206 @@ +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 +} + +type paymentsContractMock struct { + availableFunds *big.Int + approved bool + accountErr error + approvalErr error +} + +func (m *paymentsContractMock) GetAccountInfoIfSettled(context.Context, common.Address, common.Address) (*big.Int, *big.Int, *big.Int, *big.Int, error) { + if m.accountErr != nil { + return nil, nil, nil, nil, m.accountErr + } + availableFunds := big.NewInt(0) + if m.availableFunds != nil { + availableFunds = new(big.Int).Set(m.availableFunds) + } + return big.NewInt(0), big.NewInt(0), availableFunds, big.NewInt(0), nil +} + +func (m *paymentsContractMock) GetOperatorApproval(context.Context, common.Address, common.Address, common.Address) (bool, *big.Int, *big.Int, *big.Int, *big.Int, *big.Int, error) { + if m.approvalErr != nil { + return false, nil, nil, nil, nil, nil, m.approvalErr + } + return m.approved, big.NewInt(10), big.NewInt(20), big.NewInt(1), big.NewInt(2), big.NewInt(30), nil +} + +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 newPaidProviderInfo() *spregistry.ProviderInfo { + return &spregistry.ProviderInfo{ + Active: true, + ServiceProvider: common.HexToAddress("0x00000000000000000000000000000000000000cc"), + Payee: common.HexToAddress("0x00000000000000000000000000000000000000aa"), + Products: map[string]*spregistry.ServiceProduct{ + "PDP": { + IsActive: true, + Data: &spregistry.PDPOffering{ + ServiceURL: "https://provider.example", + PaymentTokenAddress: common.HexToAddress("0x00000000000000000000000000000000000000dd"), + }, + }, + }, + } +} + +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{}, + paymentsContract: &paymentsContractMock{availableFunds: big.NewInt(1), approved: true}, + } + + 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, + }}, + paymentsContract: &paymentsContractMock{availableFunds: big.NewInt(1), approved: true}, + } + + 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: newPaidProviderInfo()}, + paymentsContract: &paymentsContractMock{availableFunds: big.NewInt(1), approved: true}, + } + + state, err := manager.RunSchedule(context.Background(), schedule) + require.ErrorContains(t, err, "below the configured minimum") + require.Equal(t, model.ScheduleError, state) +} + +func TestOnChainF05PaidRunScheduleRejectsMissingPaymentsFunds(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: newPaidProviderInfo()}, + paymentsContract: &paymentsContractMock{availableFunds: big.NewInt(0), approved: true}, + } + + state, err := manager.RunSchedule(context.Background(), schedule) + require.ErrorContains(t, err, "has no available funds in payments contract") + require.Equal(t, model.ScheduleError, state) +} + +func TestOnChainF05PaidRunScheduleRejectsMissingOperatorApproval(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: newPaidProviderInfo()}, + paymentsContract: &paymentsContractMock{availableFunds: big.NewInt(100), approved: false}, + } + + state, err := manager.RunSchedule(context.Background(), schedule) + require.ErrorContains(t, err, "has not approved provider") + 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: newPaidProviderInfo()}, + paymentsContract: &paymentsContractMock{availableFunds: big.NewInt(100), approved: true}, + } + + state, err := manager.RunSchedule(context.Background(), schedule) + require.ErrorContains(t, err, "execution is not implemented yet") + require.Equal(t, model.ScheduleError, state) +} 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) diff --git a/service/dealtracker/dealtracker.go b/service/dealtracker/dealtracker.go index 87a6a5d0..f18be1db 100644 --- a/service/dealtracker/dealtracker.go +++ b/service/dealtracker/dealtracker.go @@ -97,6 +97,12 @@ type DDOAllocationTracker interface { GetAllocationInfo(ctx context.Context, allocationID uint64) (*model.DDOAllocationStatus, error) } +// F05PaymentTracker polls transaction receipts for paid-f05 payment transactions. +// Injected optionally; when nil, paid-f05 receipt tracking is skipped. +type F05PaymentTracker interface { + GetTransactionReceipt(ctx context.Context, txHash string) (*F05PaymentReceipt, error) +} + type DealTracker struct { workerID uuid.UUID dbNoContext *gorm.DB @@ -106,6 +112,7 @@ type DealTracker struct { lotusToken string once bool ddoAllocTracker DDOAllocationTracker + f05PayTracker F05PaymentTracker } // DealTrackerOption customizes DealTracker initialization. @@ -117,6 +124,12 @@ func WithDDOAllocationTracker(tracker DDOAllocationTracker) DealTrackerOption { } } +func WithF05PaymentTracker(tracker F05PaymentTracker) DealTrackerOption { + return func(dt *DealTracker) { + dt.f05PayTracker = tracker + } +} + func NewDealTracker( db *gorm.DB, interval time.Duration, @@ -639,6 +652,9 @@ func (d *DealTracker) runOnce(ctx context.Context) error { if err := d.trackDDOAllocations(ctx); err != nil { Logger.Errorw("failed to track DDO allocations", "error", err) } + if err := d.trackF05Payments(ctx); err != nil { + Logger.Errorw("failed to track paid f05 transactions", "error", err) + } return nil } @@ -684,6 +700,73 @@ func (d *DealTracker) trackDDOAllocations(ctx context.Context) error { return nil } +// trackF05Payments polls pending paid-f05 deals for payment-transaction confirmation. +func (d *DealTracker) trackF05Payments(ctx context.Context) error { + if d.f05PayTracker == nil { + return nil + } + + db := d.dbNoContext.WithContext(ctx) + var deals []model.Deal + if err := db.Where( + "deal_type = ? AND f05_payment_tx_hash IS NOT NULL AND (f05_payment_status IS NULL OR f05_payment_status NOT IN ?)", + model.DealTypeF05Paid, + []string{"confirmed", "failed"}, + ).Find(&deals).Error; err != nil { + return errors.Wrap(err, "failed to query paid f05 deals") + } + + var confirmed, failed int64 + for _, deal := range deals { + if ctx.Err() != nil { + return ctx.Err() + } + if deal.F05PaymentTxHash == nil || *deal.F05PaymentTxHash == "" { + continue + } + + receipt, err := d.f05PayTracker.GetTransactionReceipt(ctx, *deal.F05PaymentTxHash) + if err != nil { + Logger.Errorw("failed to get paid f05 transaction receipt", + "dealID", deal.ID, "txHash", *deal.F05PaymentTxHash, "error", err) + continue + } + if receipt == nil { + continue + } + + updates := map[string]any{ + "f05_payment_status": "confirmed", + "updated_at": time.Now(), + } + if receipt.Status != 1 { + updates["f05_payment_status"] = "failed" + updates["state"] = model.DealErrored + updates["error_message"] = fmt.Sprintf("paid f05 payment transaction %s failed with receipt status %d", *deal.F05PaymentTxHash, receipt.Status) + } + + if err := db.Model(&model.Deal{}).Where("id = ?", deal.ID).Updates(updates).Error; err != nil { + Logger.Errorw("failed to update paid f05 deal", + "dealID", deal.ID, "txHash", *deal.F05PaymentTxHash, "error", err) + continue + } + + if receipt.Status == 1 { + confirmed++ + } else { + failed++ + } + } + + if confirmed > 0 { + Logger.Infof("confirmed %d paid f05 payment transactions", confirmed) + } + if failed > 0 { + Logger.Infof("marked %d paid f05 payment transactions as failed", failed) + } + return nil +} + func (d *DealTracker) trackDeal(ctx context.Context, callback func(dealID uint64, deal Deal) error) error { kvstream, counter, closer, err := d.dealStateStream(ctx) if err != nil { diff --git a/service/dealtracker/f05_tracking.go b/service/dealtracker/f05_tracking.go new file mode 100644 index 00000000..b1482181 --- /dev/null +++ b/service/dealtracker/f05_tracking.go @@ -0,0 +1,85 @@ +package dealtracker + +import ( + "context" + "fmt" + + "github.com/cockroachdb/errors" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +type f05ReceiptClient interface { + TransactionReceipt(ctx context.Context, txHash common.Hash) (*ethtypes.Receipt, error) +} + +type F05PaymentReceipt struct { + Status uint64 + BlockNumber uint64 + GasUsed uint64 +} + +type F05PaymentTrackingClient struct { + client *ethclient.Client + receiptClient f05ReceiptClient +} + +func NewF05PaymentTrackingClient(ctx context.Context, rpcURL string) (*F05PaymentTrackingClient, error) { + if rpcURL == "" { + return nil, errors.New("eth rpc URL is required") + } + client, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + return nil, errors.Wrap(err, "failed to initialize paid f05 tracking client") + } + return &F05PaymentTrackingClient{ + client: client, + receiptClient: client, + }, nil +} + +func (c *F05PaymentTrackingClient) GetTransactionReceipt(ctx context.Context, txHash string) (*F05PaymentReceipt, error) { + hash, err := parseReceiptTxHash(txHash) + if err != nil { + return nil, err + } + + receipt, err := c.receiptClient.TransactionReceipt(ctx, hash) + if err != nil { + if errors.Is(err, ethereum.NotFound) { + return nil, nil + } + return nil, errors.Wrapf(err, "failed to fetch paid f05 transaction receipt %s", txHash) + } + return toF05PaymentReceipt(receipt), nil +} + +func (c *F05PaymentTrackingClient) Close() { + if c.client != nil { + c.client.Close() + } +} + +func parseReceiptTxHash(txHash string) (common.Hash, error) { + rawHash, err := hexutil.Decode(txHash) + if err != nil || len(rawHash) != common.HashLength { + return common.Hash{}, fmt.Errorf("invalid tx hash %q", txHash) + } + return common.HexToHash(txHash), nil +} + +func toF05PaymentReceipt(receipt *ethtypes.Receipt) *F05PaymentReceipt { + out := &F05PaymentReceipt{} + if receipt == nil { + return out + } + out.Status = receipt.Status + out.GasUsed = receipt.GasUsed + if receipt.BlockNumber != nil { + out.BlockNumber = receipt.BlockNumber.Uint64() + } + return out +} diff --git a/service/dealtracker/f05_tracking_test.go b/service/dealtracker/f05_tracking_test.go new file mode 100644 index 00000000..54713d6a --- /dev/null +++ b/service/dealtracker/f05_tracking_test.go @@ -0,0 +1,182 @@ +package dealtracker + +import ( + "context" + "fmt" + "testing" + + "github.com/data-preservation-programs/singularity/model" + "github.com/data-preservation-programs/singularity/util/testutil" + "github.com/ethereum/go-ethereum/common" + "github.com/ipfs/boxo/util" + "github.com/ipfs/go-cid" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type mockF05PaymentTracker struct { + receipts map[string]*F05PaymentReceipt + calls []string + err error +} + +func (m *mockF05PaymentTracker) GetTransactionReceipt(_ context.Context, txHash string) (*F05PaymentReceipt, error) { + m.calls = append(m.calls, txHash) + if m.err != nil { + return nil, m.err + } + receipt, ok := m.receipts[txHash] + if !ok { + return nil, nil + } + return receipt, nil +} + +func TestTrackF05Payments_NilTracker(t *testing.T) { + dt := &DealTracker{} + require.NoError(t, dt.trackF05Payments(context.Background())) +} + +func TestTrackF05Payments_UpdatesConfirmedAndFailedDeals(t *testing.T) { + testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + txConfirmed := common.HexToHash("0x100").Hex() + txFailed := common.HexToHash("0x200").Hex() + txPending := common.HexToHash("0x300").Hex() + txAlreadyConfirmed := common.HexToHash("0x400").Hex() + + cidA := model.CID(cid.NewCidV1(cid.Raw, util.Hash([]byte("f05-a")))) + cidB := model.CID(cid.NewCidV1(cid.Raw, util.Hash([]byte("f05-b")))) + cidC := model.CID(cid.NewCidV1(cid.Raw, util.Hash([]byte("f05-c")))) + cidD := model.CID(cid.NewCidV1(cid.Raw, util.Hash([]byte("f05-d")))) + + confirmedStatus := "confirmed" + require.NoError(t, db.Create([]model.Deal{ + { + State: model.DealProposed, + DealType: model.DealTypeF05Paid, + Provider: "f01234", + PieceCID: cidA, + PieceSize: 1024, + F05PaymentTxHash: &txConfirmed, + }, + { + State: model.DealProposed, + DealType: model.DealTypeF05Paid, + Provider: "f01234", + PieceCID: cidB, + PieceSize: 1024, + F05PaymentTxHash: &txFailed, + }, + { + State: model.DealProposed, + DealType: model.DealTypeF05Paid, + Provider: "f01234", + PieceCID: cidC, + PieceSize: 1024, + F05PaymentTxHash: &txPending, + }, + { + State: model.DealProposed, + DealType: model.DealTypeF05Paid, + Provider: "f01234", + PieceCID: cidD, + PieceSize: 1024, + F05PaymentTxHash: &txAlreadyConfirmed, + F05PaymentStatus: &confirmedStatus, + }, + }).Error) + + mock := &mockF05PaymentTracker{ + receipts: map[string]*F05PaymentReceipt{ + txConfirmed: {Status: 1, BlockNumber: 10, GasUsed: 1000}, + txFailed: {Status: 0, BlockNumber: 11, GasUsed: 2000}, + }, + } + + dt := &DealTracker{dbNoContext: db, f05PayTracker: mock} + require.NoError(t, dt.trackF05Payments(ctx)) + + require.ElementsMatch(t, []string{txConfirmed, txFailed, txPending}, mock.calls) + + var deals []model.Deal + require.NoError(t, db.Order("id asc").Find(&deals).Error) + require.Len(t, deals, 4) + + require.NotNil(t, deals[0].F05PaymentStatus) + require.Equal(t, "confirmed", *deals[0].F05PaymentStatus) + require.Equal(t, model.DealProposed, deals[0].State) + + require.NotNil(t, deals[1].F05PaymentStatus) + require.Equal(t, "failed", *deals[1].F05PaymentStatus) + require.Equal(t, model.DealErrored, deals[1].State) + require.Contains(t, deals[1].ErrorMessage, txFailed) + + require.Nil(t, deals[2].F05PaymentStatus) + require.Equal(t, model.DealProposed, deals[2].State) + + require.NotNil(t, deals[3].F05PaymentStatus) + require.Equal(t, "confirmed", *deals[3].F05PaymentStatus) + require.Equal(t, model.DealProposed, deals[3].State) + }) +} + +func TestTrackF05Payments_ContinuesOnError(t *testing.T) { + testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + txBad := common.HexToHash("0x500").Hex() + txGood := common.HexToHash("0x600").Hex() + + cidA := model.CID(cid.NewCidV1(cid.Raw, util.Hash([]byte("f05-e")))) + cidB := model.CID(cid.NewCidV1(cid.Raw, util.Hash([]byte("f05-f")))) + + require.NoError(t, db.Create([]model.Deal{ + { + State: model.DealProposed, + DealType: model.DealTypeF05Paid, + Provider: "f01234", + PieceCID: cidA, + PieceSize: 1024, + F05PaymentTxHash: &txBad, + }, + { + State: model.DealProposed, + DealType: model.DealTypeF05Paid, + Provider: "f01234", + PieceCID: cidB, + PieceSize: 1024, + F05PaymentTxHash: &txGood, + }, + }).Error) + + dt := &DealTracker{ + dbNoContext: db, + f05PayTracker: &perTxErrorF05Tracker{ + errorOn: txBad, + inner: &mockF05PaymentTracker{ + receipts: map[string]*F05PaymentReceipt{ + txGood: {Status: 1}, + }, + }, + }, + } + require.NoError(t, dt.trackF05Payments(ctx)) + + var deals []model.Deal + require.NoError(t, db.Order("id asc").Find(&deals).Error) + require.Len(t, deals, 2) + require.Nil(t, deals[0].F05PaymentStatus) + require.NotNil(t, deals[1].F05PaymentStatus) + require.Equal(t, "confirmed", *deals[1].F05PaymentStatus) + }) +} + +type perTxErrorF05Tracker struct { + inner *mockF05PaymentTracker + errorOn string +} + +func (p *perTxErrorF05Tracker) GetTransactionReceipt(ctx context.Context, txHash string) (*F05PaymentReceipt, error) { + if txHash == p.errorOn { + return nil, fmt.Errorf("rpc error for tx %s", txHash) + } + return p.inner.GetTransactionReceipt(ctx, txHash) +}