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`
|
- `POST /secret/membership/confirm`
|
||||||
- `GET /secret/membership/status`
|
- `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
|
### Governance install + availability
|
||||||
|
|
||||||
- `POST /governance/install/token`
|
- `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_MEMBER_POLL_INTERVAL_SECONDS` (default `30`)
|
||||||
- `SECRET_API_CHAIN_ID` (default `84532`)
|
- `SECRET_API_CHAIN_ID` (default `84532`)
|
||||||
- `SECRET_API_CHAIN_RPC_URL` (optional, enables on-chain tx receipt verification)
|
- `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
|
### 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) {
|
func TestMarketplaceWorkspaceAddOnRequiresCoreEntitlement(t *testing.T) {
|
||||||
a, _, cleanup := newTestApp(t)
|
a, _, cleanup := newTestApp(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
"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")
|
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())
|
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
|
||||||
return
|
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 {
|
if req.ChainID != a.cfg.ChainID {
|
||||||
writeErrorCode(w, http.StatusBadRequest, "unsupported_chain_id", fmt.Sprintf("unsupported chain_id: %d", req.ChainID))
|
writeErrorCode(w, http.StatusBadRequest, "unsupported_chain_id", fmt.Sprintf("unsupported chain_id: %d", req.ChainID))
|
||||||
return
|
return
|
||||||
@ -493,6 +501,10 @@ func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Re
|
|||||||
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "workspace_id mismatch")
|
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "workspace_id mismatch")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if payerWallet != "" && !strings.EqualFold(payerWallet, quote.PayerWallet) {
|
||||||
|
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "payer_wallet mismatch")
|
||||||
|
return
|
||||||
|
}
|
||||||
if quote.ConfirmedAt != nil {
|
if quote.ConfirmedAt != nil {
|
||||||
if existing, existingErr := a.store.getMarketplaceEntitlementByQuote(r.Context(), quote.QuoteID); existingErr == nil {
|
if existing, existingErr := a.store.getMarketplaceEntitlementByQuote(r.Context(), quote.QuoteID); existingErr == nil {
|
||||||
writeJSON(w, http.StatusOK, marketplaceCheckoutConfirmResponse{
|
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")
|
writeErrorCode(w, http.StatusConflict, "quote_already_confirmed", "quote already confirmed")
|
||||||
return
|
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()
|
now := time.Now().UTC()
|
||||||
quote.ConfirmedAt = &now
|
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.
|
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.
|
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.
|
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:
|
Remaining in this repo:
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user