819 lines
27 KiB
Go
819 lines
27 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)
|
|
|
|
offerIDSoloCore = "edut.solo.core"
|
|
offerIDWorkspaceCore = "edut.workspace.core"
|
|
offerIDWorkspaceAI = "edut.workspace.ai"
|
|
offerIDWorkspaceLane24 = "edut.workspace.lane24"
|
|
offerIDWorkspaceSovereign = "edut.workspace.sovereign"
|
|
)
|
|
|
|
func (a *app) marketplaceOffers() []marketplaceOffer {
|
|
offers := []marketplaceOffer{
|
|
{
|
|
OfferID: offerIDSoloCore,
|
|
IssuerID: "edut.firstparty",
|
|
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,
|
|
},
|
|
{
|
|
OfferID: offerIDWorkspaceCore,
|
|
IssuerID: "edut.firstparty",
|
|
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,
|
|
},
|
|
{
|
|
OfferID: offerIDWorkspaceAI,
|
|
IssuerID: "edut.firstparty",
|
|
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,
|
|
},
|
|
{
|
|
OfferID: offerIDWorkspaceLane24,
|
|
IssuerID: "edut.firstparty",
|
|
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,
|
|
},
|
|
{
|
|
OfferID: offerIDWorkspaceSovereign,
|
|
IssuerID: "edut.firstparty",
|
|
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) marketplaceOfferByID(offerID string) (marketplaceOffer, error) {
|
|
target := strings.TrimSpace(strings.ToLower(offerID))
|
|
for _, offer := range a.marketplaceOffers() {
|
|
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
|
|
}
|
|
writeJSON(w, http.StatusOK, marketplaceOffersResponse{Offers: a.marketplaceOffers()})
|
|
}
|
|
|
|
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
|
|
}
|
|
offer, err := a.marketplaceOfferByID(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
|
|
}
|
|
offer, err := a.marketplaceOfferByID(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, 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(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,
|
|
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,
|
|
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
|
|
}
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
}
|
|
records, err := a.store.listMarketplaceEntitlementsByWallet(r.Context(), wallet)
|
|
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,
|
|
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
|
|
}
|