From 86a57b28884eafe486f9124b8fb4f59903206e2a Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 18 Feb 2026 13:24:45 -0800 Subject: [PATCH] Harden marketplace checkout with on-chain tx verification --- backend/secretapi/README.md | 10 ++++- backend/secretapi/app_test.go | 70 ++++++++++++++++++++++++++++++++ backend/secretapi/chain.go | 54 ++++++++++++++++++++++++ backend/secretapi/marketplace.go | 31 ++++++++++++++ docs/roadmap-status.md | 1 + 5 files changed, 165 insertions(+), 1 deletion(-) diff --git a/backend/secretapi/README.md b/backend/secretapi/README.md index 1a9ae39..616b4a3 100644 --- a/backend/secretapi/README.md +++ b/backend/secretapi/README.md @@ -33,6 +33,14 @@ Copy `.env.example` in this folder and set contract/runtime values before deploy - `POST /secret/membership/confirm` - `GET /secret/membership/status` +### Marketplace + +- `GET /marketplace/offers` +- `GET /marketplace/offers/{offer_id}` +- `POST /marketplace/checkout/quote` +- `POST /marketplace/checkout/confirm` +- `GET /marketplace/entitlements` + ### Governance install + availability - `POST /governance/install/token` @@ -77,7 +85,7 @@ Company-first sponsor path is also supported: - `SECRET_API_MEMBER_POLL_INTERVAL_SECONDS` (default `30`) - `SECRET_API_CHAIN_ID` (default `84532`) - `SECRET_API_CHAIN_RPC_URL` (optional, enables on-chain tx receipt verification) -- `SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION` (default `false`; when `true`, membership confirm fails closed without chain receipt verification) +- `SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION` (default `false`; when `true`, membership confirm and marketplace checkout confirm fail closed without chain receipt verification) ### Membership diff --git a/backend/secretapi/app_test.go b/backend/secretapi/app_test.go index 6313290..c1328fa 100644 --- a/backend/secretapi/app_test.go +++ b/backend/secretapi/app_test.go @@ -671,6 +671,76 @@ func TestMarketplaceDistinctPayerRequiresOwnershipProof(t *testing.T) { } } +func TestMarketplaceCheckoutConfirmRejectsPayerWalletMismatch(t *testing.T) { + a, cfg, cleanup := newTestApp(t) + defer cleanup() + + ownerKey := mustKey(t) + ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) + payerKey := mustKey(t) + payerAddr := strings.ToLower(crypto.PubkeyToAddress(payerKey.PublicKey).Hex()) + if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil { + t.Fatalf("seed active membership: %v", err) + } + + quote := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{ + Wallet: ownerAddr, + PayerWallet: payerAddr, + OfferID: offerIDWorkspaceCore, + OwnershipProof: "0x" + strings.Repeat("a", 130), + OrgRootID: "org.marketplace.payer", + PrincipalID: "human.owner", + PrincipalRole: "org_root_owner", + }, http.StatusOK) + + errResp := postJSONExpect[map[string]string](t, a, "/marketplace/checkout/confirm", marketplaceCheckoutConfirmRequest{ + QuoteID: quote.QuoteID, + Wallet: ownerAddr, + PayerWallet: ownerAddr, + OfferID: offerIDWorkspaceCore, + OrgRootID: "org.marketplace.payer", + PrincipalID: "human.owner", + PrincipalRole: "org_root_owner", + TxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ChainID: cfg.ChainID, + }, http.StatusBadRequest) + if code := errResp["code"]; code != "context_mismatch" { + t.Fatalf("expected context_mismatch, got %+v", errResp) + } +} + +func TestMarketplaceConfirmRequiresChainRPCWhenStrictVerificationEnabled(t *testing.T) { + a, cfg, cleanup := newTestApp(t) + defer cleanup() + a.cfg.RequireOnchainTxVerify = true + a.cfg.ChainRPCURL = "" + + ownerKey := mustKey(t) + ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) + + quote := postJSONExpect[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{ + Wallet: ownerAddr, + OfferID: offerIDWorkspaceCore, + OrgRootID: "org.marketplace.strict", + PrincipalID: "human.owner", + PrincipalRole: "org_root_owner", + }, http.StatusOK) + + errResp := postJSONExpect[map[string]string](t, a, "/marketplace/checkout/confirm", marketplaceCheckoutConfirmRequest{ + QuoteID: quote.QuoteID, + Wallet: ownerAddr, + OfferID: offerIDWorkspaceCore, + OrgRootID: "org.marketplace.strict", + PrincipalID: "human.owner", + PrincipalRole: "org_root_owner", + TxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ChainID: cfg.ChainID, + }, http.StatusServiceUnavailable) + if code := errResp["code"]; code != "chain_verification_unavailable" { + t.Fatalf("expected chain_verification_unavailable, got %+v", errResp) + } +} + func TestMarketplaceWorkspaceAddOnRequiresCoreEntitlement(t *testing.T) { a, _, cleanup := newTestApp(t) defer cleanup() diff --git a/backend/secretapi/chain.go b/backend/secretapi/chain.go index 404e961..d3795ed 100644 --- a/backend/secretapi/chain.go +++ b/backend/secretapi/chain.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "math/big" "strings" "github.com/ethereum/go-ethereum/accounts/abi" @@ -73,3 +74,56 @@ func verifyMintedOnChain(ctx context.Context, cfg Config, txHash string, expecte } return fmt.Errorf("membership mint event not found for wallet") } + +func verifyTransactionSenderOnChain(ctx context.Context, cfg Config, txHash string, expectedSender string) error { + if strings.TrimSpace(cfg.ChainRPCURL) == "" { + return nil + } + expectedSender = strings.TrimSpace(expectedSender) + if expectedSender == "" { + return fmt.Errorf("expected sender missing") + } + + client, err := ethclient.DialContext(ctx, cfg.ChainRPCURL) + if err != nil { + return fmt.Errorf("dial chain rpc: %w", err) + } + defer client.Close() + + hash := common.HexToHash(txHash) + receipt, err := client.TransactionReceipt(ctx, hash) + if err != nil { + return fmt.Errorf("read tx receipt: %w", err) + } + if receipt == nil { + return fmt.Errorf("tx receipt not found") + } + if receipt.Status != types.ReceiptStatusSuccessful { + return fmt.Errorf("tx failed status=%d", receipt.Status) + } + + tx, pending, err := client.TransactionByHash(ctx, hash) + if err != nil { + return fmt.Errorf("read tx body: %w", err) + } + if pending { + return fmt.Errorf("tx pending") + } + + chainID := tx.ChainId() + if chainID == nil || chainID.Sign() <= 0 { + chainID = big.NewInt(cfg.ChainID) + } + signer := types.LatestSignerForChainID(chainID) + from, err := types.Sender(signer, tx) + if err != nil { + return fmt.Errorf("resolve tx sender: %w", err) + } + + gotSender := strings.ToLower(from.Hex()) + wantSender := strings.ToLower(common.HexToAddress(expectedSender).Hex()) + if gotSender != wantSender { + return fmt.Errorf("tx sender mismatch got=%s want=%s", gotSender, wantSender) + } + return nil +} diff --git a/backend/secretapi/marketplace.go b/backend/secretapi/marketplace.go index f1a2f09..933a7b0 100644 --- a/backend/secretapi/marketplace.go +++ b/backend/secretapi/marketplace.go @@ -451,6 +451,14 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error()) return } + payerWallet := "" + if strings.TrimSpace(req.PayerWallet) != "" { + payerWallet, err = normalizeAddress(req.PayerWallet) + if err != nil { + writeErrorCode(w, http.StatusBadRequest, "invalid_payer_wallet", err.Error()) + return + } + } if req.ChainID != a.cfg.ChainID { writeErrorCode(w, http.StatusBadRequest, "unsupported_chain_id", fmt.Sprintf("unsupported chain_id: %d", req.ChainID)) return @@ -493,6 +501,10 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "workspace_id mismatch") return } + if payerWallet != "" && !strings.EqualFold(payerWallet, quote.PayerWallet) { + writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "payer_wallet mismatch") + return + } if quote.ConfirmedAt != nil { if existing, existingErr := a.store.getMarketplaceEntitlementByQuote(r.Context(), quote.QuoteID); existingErr == nil { writeJSON(w, http.StatusOK, marketplaceCheckoutConfirmResponse{ @@ -514,6 +526,25 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re writeErrorCode(w, http.StatusConflict, "quote_already_confirmed", "quote already confirmed") return } + if a.cfg.RequireOnchainTxVerify && strings.TrimSpace(a.cfg.ChainRPCURL) == "" { + writeErrorCode(w, http.StatusServiceUnavailable, "chain_verification_unavailable", "chain rpc required for checkout confirmation") + return + } + + expectedPayer := strings.TrimSpace(quote.PayerWallet) + if expectedPayer == "" { + expectedPayer = wallet + } + if err := verifyTransactionSenderOnChain(r.Context(), a.cfg, req.TxHash, expectedPayer); err != nil { + writeErrorCode(w, http.StatusConflict, "tx_verification_failed", fmt.Sprintf("tx verification pending/failed: %v", err)) + return + } + if quote.MembershipIncluded { + if err := verifyMintedOnChain(r.Context(), a.cfg, req.TxHash, wallet); err != nil { + writeErrorCode(w, http.StatusConflict, "membership_verification_failed", fmt.Sprintf("membership verification pending/failed: %v", err)) + return + } + } now := time.Now().UTC() quote.ConfirmedAt = &now diff --git a/docs/roadmap-status.md b/docs/roadmap-status.md index 0eed5af..383ea46 100644 --- a/docs/roadmap-status.md +++ b/docs/roadmap-status.md @@ -57,6 +57,7 @@ Implemented now: 28. Membership confirm now supports strict fail-closed mode (`SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION`) that requires chain receipt verification when enabled. 29. `secretapi` now validates critical config at startup and fails fast on invalid deploy combinations. 30. `secretapi` now ships an explicit `.env.example` deployment template aligned to current endpoint/runtime requirements. +31. Marketplace checkout confirm now validates on-chain tx sender/receipt and supports strict fail-closed verification mode. Remaining in this repo: