web/backend/secretapi/marketplace.go
Joshua b8d9147f5c
Some checks are pending
check / secretapi (push) Waiting to run
Add merchant-scoped marketplace checkout plumbing
2026-02-19 15:12:56 -08:00

865 lines
29 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"sort"
"strconv"
"strings"
"time"
)
const (
marketplaceMembershipActivationAtomic = "100000000" // 100.00 USDC (6 decimals)
marketplaceStandardOfferAtomic = "1000000000" // 1000.00 USDC (6 decimals)
defaultMarketplaceMerchantID = "edut.firstparty"
offerIDSoloCore = "edut.solo.core"
offerIDWorkspaceCore = "edut.workspace.core"
offerIDWorkspaceAI = "edut.workspace.ai"
offerIDWorkspaceLane24 = "edut.workspace.lane24"
offerIDWorkspaceSovereign = "edut.workspace.sovereign"
)
func normalizeMerchantID(raw string) string {
merchantID := strings.ToLower(strings.TrimSpace(raw))
if merchantID == "" {
return defaultMarketplaceMerchantID
}
return merchantID
}
func marketplaceContractOfferID(merchantID, offerID string) string {
merchantID = normalizeMerchantID(merchantID)
offerID = strings.TrimSpace(strings.ToLower(offerID))
if merchantID == defaultMarketplaceMerchantID {
return offerID
}
return merchantID + ":" + offerID
}
func (a *app) marketplaceOffersForMerchant(merchantID string) []marketplaceOffer {
merchantID = normalizeMerchantID(merchantID)
if merchantID != defaultMarketplaceMerchantID {
return []marketplaceOffer{}
}
offers := []marketplaceOffer{
{
MerchantID: defaultMarketplaceMerchantID,
OfferID: offerIDSoloCore,
IssuerID: defaultMarketplaceMerchantID,
Title: "EDUT Solo Core",
Summary: "Single-principal governance runtime for personal operations.",
Status: "active",
Pricing: marketplaceOfferPrice{
Currency: "USDC",
AmountAtomic: marketplaceStandardOfferAtomic,
Decimals: 6,
ChainID: a.cfg.ChainID,
},
Policies: marketplaceOfferPolicy{
MemberOnly: true,
WorkspaceBound: true,
Transferable: false,
InternalUseOnly: true,
MultiTenant: false,
EntitlementClass: "solo_core",
},
ExecutionProfile: marketplaceExecutionProfile{
ConnectorSurface: "hybrid",
PacingTier: "governed_human_pace",
HumanPaceFloorMS: 1200,
},
SortOrder: 10,
},
{
MerchantID: defaultMarketplaceMerchantID,
OfferID: offerIDWorkspaceCore,
IssuerID: defaultMarketplaceMerchantID,
Title: "EDUT Workspace Core",
Summary: "Org-bound deterministic governance runtime for team operations.",
Status: "active",
Pricing: marketplaceOfferPrice{
Currency: "USDC",
AmountAtomic: marketplaceStandardOfferAtomic,
Decimals: 6,
ChainID: a.cfg.ChainID,
},
Policies: marketplaceOfferPolicy{
MemberOnly: true,
WorkspaceBound: true,
Transferable: false,
InternalUseOnly: true,
MultiTenant: false,
EntitlementClass: "workspace_core",
},
ExecutionProfile: marketplaceExecutionProfile{
ConnectorSurface: "hybrid",
PacingTier: "governed_human_pace",
HumanPaceFloorMS: 1200,
},
SortOrder: 20,
},
{
MerchantID: defaultMarketplaceMerchantID,
OfferID: offerIDWorkspaceAI,
IssuerID: defaultMarketplaceMerchantID,
Title: "EDUT Workspace AI Layer",
Summary: "AI reasoning layer for governed workspace operations.",
Status: "active",
Pricing: marketplaceOfferPrice{
Currency: "USDC",
AmountAtomic: marketplaceStandardOfferAtomic,
Decimals: 6,
ChainID: a.cfg.ChainID,
},
Policies: marketplaceOfferPolicy{
MemberOnly: true,
WorkspaceBound: true,
Transferable: false,
InternalUseOnly: true,
MultiTenant: false,
EntitlementClass: "workspace_ai",
RequiresOffers: []string{offerIDWorkspaceCore},
},
ExecutionProfile: marketplaceExecutionProfile{
ConnectorSurface: "hybrid",
PacingTier: "governed_human_pace",
HumanPaceFloorMS: 1200,
},
SortOrder: 30,
},
{
MerchantID: defaultMarketplaceMerchantID,
OfferID: offerIDWorkspaceLane24,
IssuerID: defaultMarketplaceMerchantID,
Title: "EDUT Workspace 24h Lane",
Summary: "Autonomous execution lane capacity for workspace queue throughput.",
Status: "active",
Pricing: marketplaceOfferPrice{
Currency: "USDC",
AmountAtomic: marketplaceStandardOfferAtomic,
Decimals: 6,
ChainID: a.cfg.ChainID,
},
Policies: marketplaceOfferPolicy{
MemberOnly: true,
WorkspaceBound: true,
Transferable: false,
InternalUseOnly: true,
MultiTenant: false,
EntitlementClass: "workspace_lane24",
RequiresOffers: []string{offerIDWorkspaceCore},
},
ExecutionProfile: marketplaceExecutionProfile{
ConnectorSurface: "edut_native",
PacingTier: "local_hardware_speed",
},
SortOrder: 40,
},
{
MerchantID: defaultMarketplaceMerchantID,
OfferID: offerIDWorkspaceSovereign,
IssuerID: defaultMarketplaceMerchantID,
Title: "EDUT Workspace Sovereign Continuity",
Summary: "Workspace continuity profile for stronger local/offline operation.",
Status: "active",
Pricing: marketplaceOfferPrice{
Currency: "USDC",
AmountAtomic: marketplaceStandardOfferAtomic,
Decimals: 6,
ChainID: a.cfg.ChainID,
},
Policies: marketplaceOfferPolicy{
MemberOnly: true,
WorkspaceBound: true,
Transferable: false,
InternalUseOnly: true,
MultiTenant: false,
EntitlementClass: "workspace_sovereign",
RequiresOffers: []string{offerIDWorkspaceCore},
},
ExecutionProfile: marketplaceExecutionProfile{
ConnectorSurface: "edut_native",
PacingTier: "local_hardware_speed",
},
SortOrder: 50,
},
}
sort.SliceStable(offers, func(i, j int) bool {
return offers[i].SortOrder < offers[j].SortOrder
})
return offers
}
func (a *app) marketplaceOffers() []marketplaceOffer {
return a.marketplaceOffersForMerchant(defaultMarketplaceMerchantID)
}
func (a *app) marketplaceOfferByID(merchantID, offerID string) (marketplaceOffer, error) {
target := strings.TrimSpace(strings.ToLower(offerID))
for _, offer := range a.marketplaceOffersForMerchant(merchantID) {
if strings.EqualFold(strings.TrimSpace(offer.OfferID), target) {
return offer, nil
}
}
return marketplaceOffer{}, errNotFound
}
func isSoloOffer(offerID string) bool {
return strings.EqualFold(strings.TrimSpace(offerID), offerIDSoloCore)
}
func isWorkspaceOffer(offerID string) bool {
switch strings.ToLower(strings.TrimSpace(offerID)) {
case offerIDWorkspaceCore, offerIDWorkspaceAI, offerIDWorkspaceLane24, offerIDWorkspaceSovereign:
return true
default:
return false
}
}
func shouldBindPrincipalOnOffer(offerID string) bool {
switch strings.ToLower(strings.TrimSpace(offerID)) {
case offerIDSoloCore, offerIDWorkspaceCore:
return true
default:
return false
}
}
func defaultSoloOrgRootID(wallet string) string {
return "solo:" + strings.ToLower(strings.TrimSpace(wallet))
}
func (a *app) handleMarketplaceOffers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
merchantID := normalizeMerchantID(r.URL.Query().Get("merchant_id"))
writeJSON(w, http.StatusOK, marketplaceOffersResponse{Offers: a.marketplaceOffersForMerchant(merchantID)})
}
func (a *app) handleMarketplaceOfferByID(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
const prefix = "/marketplace/offers/"
offerID := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, prefix))
if offerID == "" || strings.Contains(offerID, "/") {
writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found")
return
}
merchantID := normalizeMerchantID(r.URL.Query().Get("merchant_id"))
offer, err := a.marketplaceOfferByID(merchantID, offerID)
if err != nil {
writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found")
return
}
writeJSON(w, http.StatusOK, offer)
}
func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req marketplaceCheckoutQuoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
return
}
wallet, err := normalizeAddress(req.Wallet)
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return
}
if !a.enforceWalletSession(w, r, wallet) {
return
}
merchantID := normalizeMerchantID(req.MerchantID)
offer, err := a.marketplaceOfferByID(merchantID, req.OfferID)
if err != nil {
writeErrorCode(w, http.StatusNotFound, "offer_not_found", "offer not found")
return
}
payerWallet := wallet
if strings.TrimSpace(req.PayerWallet) != "" {
payerWallet, err = normalizeAddress(req.PayerWallet)
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_payer_wallet", err.Error())
return
}
}
if !strings.EqualFold(wallet, payerWallet) {
if !isLikelyHexSignature(req.OwnershipProof) {
writeErrorCode(w, http.StatusForbidden, "ownership_proof_required", "distinct payer requires ownership proof")
return
}
}
orgRootID := strings.TrimSpace(req.OrgRootID)
principalID := strings.TrimSpace(req.PrincipalID)
if principalID == "" {
principalID = wallet
}
principalRole := strings.ToLower(strings.TrimSpace(req.PrincipalRole))
if principalRole == "" {
principalRole = "org_root_owner"
}
workspaceID := strings.TrimSpace(req.WorkspaceID)
if isSoloOffer(offer.OfferID) {
expectedSoloRoot := defaultSoloOrgRootID(wallet)
if orgRootID == "" {
orgRootID = expectedSoloRoot
}
if !strings.EqualFold(orgRootID, expectedSoloRoot) {
writeErrorCode(w, http.StatusBadRequest, "invalid_scope", "solo core must use wallet-scoped solo org root")
return
}
if principalRole != "org_root_owner" {
writeErrorCode(w, http.StatusForbidden, "role_insufficient", "solo core checkout requires org_root_owner role")
return
}
if workspaceID != "" {
writeErrorCode(w, http.StatusBadRequest, "invalid_scope", "solo core does not bind workspace_id")
return
}
}
if isWorkspaceOffer(offer.OfferID) {
if orgRootID == "" {
writeErrorCode(w, http.StatusBadRequest, "invalid_scope", "workspace offer requires org_root_id")
return
}
if principalRole != "org_root_owner" {
writeErrorCode(w, http.StatusForbidden, "role_insufficient", "workspace checkout requires org_root_owner role")
return
}
}
for _, requiredOfferID := range offer.Policies.RequiresOffers {
hasRequired, err := a.store.hasActiveEntitlement(r.Context(), wallet, merchantID, requiredOfferID, orgRootID)
if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve prerequisite entitlements")
return
}
if !hasRequired {
writeErrorCode(w, http.StatusForbidden, "prerequisite_required", fmt.Sprintf("active entitlement required: %s", requiredOfferID))
return
}
}
includeMembership := true
if req.IncludeMembershipIfMissing != nil {
includeMembership = *req.IncludeMembershipIfMissing
}
membershipStatus, statusErr := a.resolveMembershipStatusForWallet(r, wallet)
if statusErr != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve membership status")
return
}
membershipIncluded := false
switch membershipStatus {
case "active":
// no-op
case "none", "unknown", "":
if includeMembership {
membershipIncluded = true
} else {
writeErrorCode(w, http.StatusForbidden, "membership_required", "active membership is required for checkout")
return
}
case "suspended", "revoked":
writeErrorCode(w, http.StatusForbidden, "membership_required", "active membership is required for checkout")
return
default:
if includeMembership {
membershipIncluded = true
} else {
writeErrorCode(w, http.StatusForbidden, "membership_required", "active membership is required for checkout")
return
}
}
licenseAmount := offer.Pricing.AmountAtomic
totalAmount := new(big.Int)
if _, ok := totalAmount.SetString(licenseAmount, 10); !ok {
writeErrorCode(w, http.StatusInternalServerError, "pricing_invalid", "offer pricing is invalid")
return
}
lineItems := []marketplaceQuoteLineItem{
{
Kind: "license",
Label: offer.Title,
Amount: formatAtomicAmount(licenseAmount, offer.Pricing.Decimals),
AmountAtomic: licenseAmount,
Decimals: offer.Pricing.Decimals,
Currency: offer.Pricing.Currency,
},
}
if membershipIncluded {
membershipAmount := new(big.Int)
if _, ok := membershipAmount.SetString(marketplaceMembershipActivationAtomic, 10); !ok {
writeErrorCode(w, http.StatusInternalServerError, "pricing_invalid", "membership pricing is invalid")
return
}
totalAmount = new(big.Int).Add(totalAmount, membershipAmount)
lineItems = append(lineItems, marketplaceQuoteLineItem{
Kind: "membership",
Label: "EDUT Membership Activation",
Amount: formatAtomicAmount(marketplaceMembershipActivationAtomic, offer.Pricing.Decimals),
AmountAtomic: marketplaceMembershipActivationAtomic,
Decimals: offer.Pricing.Decimals,
Currency: offer.Pricing.Currency,
})
}
lineItemsJSON, err := json.Marshal(lineItems)
if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "encoding_failed", "failed to encode quote line items")
return
}
quoteIDRaw, err := randomHex(16)
if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to generate quote id")
return
}
entitlementContract := strings.ToLower(strings.TrimSpace(a.cfg.EntitlementContract))
if entitlementContract == "" || strings.EqualFold(entitlementContract, "0x0000000000000000000000000000000000000000") {
writeErrorCode(w, http.StatusServiceUnavailable, "entitlement_contract_unconfigured", "entitlement contract is not configured")
return
}
txTo := entitlementContract
txValueHex := "0x0"
txData, calldataErr := encodePurchaseEntitlementCalldata(marketplaceContractOfferID(merchantID, offer.OfferID), wallet, orgRootID, workspaceID)
if calldataErr != nil {
writeErrorCode(w, http.StatusInternalServerError, "quote_generation_failed", "failed to encode checkout transaction")
return
}
now := time.Now().UTC()
expiresAt := now.Add(a.cfg.QuoteTTL)
accessClass := "connected"
if strings.EqualFold(strings.TrimSpace(offer.OfferID), offerIDWorkspaceSovereign) {
accessClass = "sovereign"
}
quote := marketplaceQuoteRecord{
QuoteID: "cq_" + quoteIDRaw,
MerchantID: merchantID,
Wallet: wallet,
PayerWallet: payerWallet,
OfferID: offer.OfferID,
OrgRootID: orgRootID,
PrincipalID: principalID,
PrincipalRole: principalRole,
WorkspaceID: workspaceID,
Currency: offer.Pricing.Currency,
AmountAtomic: licenseAmount,
TotalAmountAtomic: totalAmount.String(),
Decimals: offer.Pricing.Decimals,
MembershipIncluded: membershipIncluded,
LineItemsJSON: string(lineItemsJSON),
PolicyHash: a.cfg.GovernancePolicyHash,
AccessClass: accessClass,
AvailabilityState: "active",
ExpectedTxTo: txTo,
ExpectedTxData: txData,
ExpectedTxValueHex: txValueHex,
CreatedAt: now,
ExpiresAt: expiresAt,
}
if err := a.store.putMarketplaceQuote(r.Context(), quote); err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist quote")
return
}
writeJSON(w, http.StatusOK, marketplaceCheckoutQuoteResponse{
QuoteID: quote.QuoteID,
MerchantID: quote.MerchantID,
Wallet: quote.Wallet,
PayerWallet: quote.PayerWallet,
OfferID: quote.OfferID,
OrgRootID: quote.OrgRootID,
PrincipalID: quote.PrincipalID,
PrincipalRole: quote.PrincipalRole,
Currency: quote.Currency,
Amount: formatAtomicAmount(quote.AmountAtomic, quote.Decimals),
AmountAtomic: quote.AmountAtomic,
TotalAmount: formatAtomicAmount(quote.TotalAmountAtomic, quote.Decimals),
TotalAmountAtomic: quote.TotalAmountAtomic,
Decimals: quote.Decimals,
MembershipActivationIncluded: quote.MembershipIncluded,
LineItems: lineItems,
PolicyHash: quote.PolicyHash,
ExpiresAt: quote.ExpiresAt.Format(time.RFC3339Nano),
Tx: map[string]any{
"from": quote.PayerWallet,
"to": quote.ExpectedTxTo,
"value": quote.ExpectedTxValueHex,
"data": quote.ExpectedTxData,
},
AccessClass: quote.AccessClass,
AvailabilityState: quote.AvailabilityState,
})
}
func (a *app) handleMarketplaceCheckoutConfirm(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req marketplaceCheckoutConfirmRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
return
}
wallet, err := normalizeAddress(req.Wallet)
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return
}
if !a.enforceWalletSession(w, r, wallet) {
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
}
if !isTxHash(req.TxHash) {
writeErrorCode(w, http.StatusBadRequest, "invalid_tx_hash", "invalid tx hash")
return
}
quote, err := a.store.getMarketplaceQuote(r.Context(), strings.TrimSpace(req.QuoteID))
if err != nil {
if errors.Is(err, errNotFound) {
writeErrorCode(w, http.StatusNotFound, "quote_not_found", "quote not found")
return
}
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to load quote")
return
}
if time.Now().UTC().After(quote.ExpiresAt) {
writeErrorCode(w, http.StatusConflict, "quote_expired", "quote expired")
return
}
merchantID := normalizeMerchantID(req.MerchantID)
if !strings.EqualFold(quote.MerchantID, merchantID) {
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "merchant_id mismatch")
return
}
if !strings.EqualFold(quote.Wallet, wallet) ||
!strings.EqualFold(strings.TrimSpace(quote.OfferID), strings.TrimSpace(req.OfferID)) {
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "quote context mismatch")
return
}
if strings.TrimSpace(req.OrgRootID) != "" && !strings.EqualFold(strings.TrimSpace(req.OrgRootID), quote.OrgRootID) {
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "org_root_id mismatch")
return
}
if strings.TrimSpace(req.PrincipalID) != "" && !strings.EqualFold(strings.TrimSpace(req.PrincipalID), quote.PrincipalID) {
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "principal_id mismatch")
return
}
if strings.TrimSpace(req.PrincipalRole) != "" && !strings.EqualFold(strings.TrimSpace(req.PrincipalRole), quote.PrincipalRole) {
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "principal_role mismatch")
return
}
if strings.TrimSpace(req.WorkspaceID) != "" && !strings.EqualFold(strings.TrimSpace(req.WorkspaceID), quote.WorkspaceID) {
writeErrorCode(w, http.StatusBadRequest, "context_mismatch", "workspace_id mismatch")
return
}
if existingQuoteID, lookupErr := a.store.getMarketplaceQuoteIDByConfirmedTxHash(r.Context(), req.TxHash); lookupErr == nil {
if !strings.EqualFold(existingQuoteID, quote.QuoteID) {
writeErrorCode(w, http.StatusConflict, "tx_hash_replay", "tx hash already used for a different checkout confirmation")
return
}
} else if lookupErr != errNotFound {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve checkout tx hash reuse")
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{
Status: "entitlement_active",
EntitlementID: existing.EntitlementID,
MerchantID: existing.MerchantID,
OfferID: existing.OfferID,
OrgRootID: existing.OrgRootID,
PrincipalID: existing.PrincipalID,
PrincipalRole: existing.PrincipalRole,
Wallet: existing.Wallet,
TxHash: existing.TxHash,
PolicyHash: existing.PolicyHash,
ActivatedAt: existing.IssuedAt.Format(time.RFC3339Nano),
AccessClass: existing.AccessClass,
AvailabilityState: existing.AvailabilityState,
})
return
}
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 := verifyTransactionCallOnChain(
r.Context(),
a.cfg,
req.TxHash,
expectedPayer,
quote.ExpectedTxTo,
quote.ExpectedTxData,
quote.ExpectedTxValueHex,
); 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
quote.ConfirmedTxHash = strings.ToLower(strings.TrimSpace(req.TxHash))
if err := a.store.putMarketplaceQuote(r.Context(), quote); err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist quote confirmation")
return
}
if quote.MembershipIncluded {
assurance := assuranceCryptoDirect
if strings.TrimSpace(quote.PayerWallet) != "" && !strings.EqualFold(strings.TrimSpace(quote.PayerWallet), wallet) {
assurance = assuranceSponsoredUnattested
}
if err := a.ensureMembershipActiveForWallet(r.Context(), wallet, quote.ConfirmedTxHash, assurance); err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to activate membership")
return
}
}
entitlementID := buildEntitlementID(a.cfg.ChainID, wallet)
ent := marketplaceEntitlementRecord{
EntitlementID: entitlementID,
QuoteID: quote.QuoteID,
MerchantID: quote.MerchantID,
OfferID: quote.OfferID,
Wallet: wallet,
PayerWallet: quote.PayerWallet,
OrgRootID: quote.OrgRootID,
PrincipalID: quote.PrincipalID,
PrincipalRole: quote.PrincipalRole,
WorkspaceID: quote.WorkspaceID,
State: "active",
AccessClass: quote.AccessClass,
AvailabilityState: quote.AvailabilityState,
PolicyHash: quote.PolicyHash,
IssuedAt: now,
TxHash: quote.ConfirmedTxHash,
}
if err := a.store.putMarketplaceEntitlement(r.Context(), ent); err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to persist entitlement")
return
}
if shouldBindPrincipalOnOffer(quote.OfferID) {
principal, principalErr := a.resolveOrCreatePrincipal(r.Context(), wallet, quote.OrgRootID, quote.PrincipalID, quote.PrincipalRole)
if principalErr == nil {
principal.EntitlementID = ent.EntitlementID
principal.EntitlementStatus = "active"
principal.AccessClass = quote.AccessClass
principal.AvailabilityState = quote.AvailabilityState
principal.UpdatedAt = now
_ = a.store.putGovernancePrincipal(r.Context(), principal)
}
}
writeJSON(w, http.StatusOK, marketplaceCheckoutConfirmResponse{
Status: "entitlement_active",
EntitlementID: ent.EntitlementID,
MerchantID: ent.MerchantID,
OfferID: ent.OfferID,
OrgRootID: ent.OrgRootID,
PrincipalID: ent.PrincipalID,
PrincipalRole: ent.PrincipalRole,
Wallet: ent.Wallet,
TxHash: ent.TxHash,
PolicyHash: ent.PolicyHash,
ActivatedAt: ent.IssuedAt.Format(time.RFC3339Nano),
AccessClass: ent.AccessClass,
AvailabilityState: ent.AvailabilityState,
})
}
func (a *app) handleMarketplaceEntitlements(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
wallet, err := normalizeAddress(strings.TrimSpace(r.URL.Query().Get("wallet")))
if err != nil {
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
return
}
if !a.enforceWalletSession(w, r, wallet) {
return
}
merchantID := strings.TrimSpace(r.URL.Query().Get("merchant_id"))
records, err := a.store.listMarketplaceEntitlementsByWallet(r.Context(), wallet, merchantID)
if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to list entitlements")
return
}
out := make([]marketplaceEntitlement, 0, len(records))
for _, rec := range records {
out = append(out, marketplaceEntitlement{
EntitlementID: rec.EntitlementID,
MerchantID: rec.MerchantID,
OfferID: rec.OfferID,
WalletAddress: rec.Wallet,
WorkspaceID: rec.WorkspaceID,
OrgRootID: rec.OrgRootID,
State: rec.State,
AccessClass: rec.AccessClass,
AvailabilityState: rec.AvailabilityState,
PolicyHash: rec.PolicyHash,
IssuedAt: rec.IssuedAt.Format(time.RFC3339Nano),
})
}
writeJSON(w, http.StatusOK, marketplaceEntitlementsResponse{Entitlements: out})
}
func (a *app) resolveMembershipStatusForWallet(r *http.Request, wallet string) (string, error) {
rec, err := a.store.getDesignationByAddress(r.Context(), wallet)
if err != nil {
if errors.Is(err, errNotFound) {
return "none", nil
}
return "unknown", err
}
status := strings.ToLower(strings.TrimSpace(rec.MembershipStatus))
if status == "" {
status = "none"
}
return status, nil
}
func (a *app) ensureMembershipActiveForWallet(ctx context.Context, wallet, txHash, assurance string) error {
rec, err := a.store.getDesignationByAddress(ctx, wallet)
if err != nil {
if !errors.Is(err, errNotFound) {
return err
}
now := time.Now().UTC()
intentID, err := randomHex(16)
if err != nil {
return err
}
nonce, err := randomHex(16)
if err != nil {
return err
}
code, displayToken, err := newDesignationCode()
if err != nil {
return err
}
rec = designationRecord{
Code: code,
DisplayToken: displayToken,
IntentID: intentID,
Nonce: nonce,
Origin: "edut-launcher",
Locale: "en",
Address: wallet,
ChainID: a.cfg.ChainID,
IssuedAt: now,
ExpiresAt: now.Add(a.cfg.IntentTTL),
VerifiedAt: &now,
MembershipStatus: "none",
IdentityAssurance: assuranceNone,
}
}
now := time.Now().UTC()
rec.MembershipStatus = "active"
rec.MembershipTxHash = strings.ToLower(strings.TrimSpace(txHash))
rec.ActivatedAt = &now
rec.IdentityAssurance = normalizeAssuranceLevel(assurance)
return a.store.putDesignation(ctx, rec)
}
func buildEntitlementID(chainID int64, wallet string) string {
token, _ := randomHex(4)
return fmt.Sprintf("ent:%d:%s:%s", chainID, wallet, token)
}
func formatAtomicAmount(amountAtomic string, decimals int) string {
amountAtomic = strings.TrimSpace(amountAtomic)
if amountAtomic == "" {
return "0"
}
n := new(big.Int)
if _, ok := n.SetString(amountAtomic, 10); !ok {
return "0"
}
sign := ""
if n.Sign() < 0 {
sign = "-"
n.Abs(n)
}
raw := n.String()
if decimals <= 0 {
return sign + raw
}
if len(raw) <= decimals {
raw = strings.Repeat("0", decimals-len(raw)+1) + raw
}
whole := raw[:len(raw)-decimals]
fraction := strings.TrimRight(raw[len(raw)-decimals:], "0")
if fraction == "" {
return sign + whole
}
return sign + whole + "." + fraction
}
func isLikelyHexSignature(value string) bool {
value = strings.TrimSpace(strings.ToLower(value))
if !strings.HasPrefix(value, "0x") || len(value) < 132 {
return false
}
_, err := strconv.ParseUint(value[2:18], 16, 64)
return err == nil
}