Harden marketplace checkout with on-chain tx verification
This commit is contained in:
parent
5a857f5554
commit
86a57b2888
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user