Harden marketplace checkout with on-chain tx verification

This commit is contained in:
Joshua 2026-02-18 13:24:45 -08:00
parent 5a857f5554
commit 86a57b2888
5 changed files with 165 additions and 1 deletions

View File

@ -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

View File

@ -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()

View File

@ -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
}

View File

@ -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

View File

@ -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: