diff --git a/backend/secretapi/README.md b/backend/secretapi/README.md index aa3aaca..47794b7 100644 --- a/backend/secretapi/README.md +++ b/backend/secretapi/README.md @@ -120,6 +120,16 @@ Policy gates: 1. Store checkout requires active membership. 2. Workspace admin install/support actions require `onramp_attested` assurance. +## Quote Cost Envelope + +`POST /secret/membership/quote` and `POST /marketplace/checkout/quote` return a deterministic `cost_envelope` object. + +The envelope is pre-execution pricing metadata and is authoritative for checkout presentation: + +1. `checkout_total_atomic` and `checkout_total` are the user checkout totals. +2. `provider_fee_policy=edut_absorbed` means on-ramp processing fees are absorbed by EDUT. +3. `network_fee_policy=payer_wallet_pays_chain_gas` means chain gas remains wallet-dependent and separate from checkout total. + ## Key Environment Variables ### Core @@ -127,10 +137,14 @@ Policy gates: - `SECRET_API_LISTEN_ADDR` (default `:8080`) - `SECRET_API_DB_PATH` (default `./secret.db`) - `SECRET_API_ALLOWED_ORIGIN` (default `https://edut.ai`) +- `SECRET_API_DEPLOYMENT_CLASS` (`development|staging|production`; default `development`) - `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 and marketplace checkout confirm fail closed without chain receipt verification) +- `SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION`: + - if explicitly set, value is honored. + - if unset, defaults to `true` when `SECRET_API_DEPLOYMENT_CLASS=production`, else `false`. + - when enabled, membership confirm and marketplace checkout confirm fail closed without chain receipt verification. - `SECRET_API_ENTITLEMENT_CONTRACT` (optional; when set, marketplace quote emits purchase calldata for entitlement settlement contract) ### Membership diff --git a/backend/secretapi/app.go b/backend/secretapi/app.go index af5ba59..0dbb56f 100644 --- a/backend/secretapi/app.go +++ b/backend/secretapi/app.go @@ -470,6 +470,7 @@ func (a *app) handleMembershipQuote(w http.ResponseWriter, r *http.Request) { Currency: quote.Currency, AmountAtomic: quote.AmountAtomic, Decimals: quote.Decimals, + CostEnvelope: newQuoteCostEnvelope(quote.Currency, quote.Decimals, quote.AmountAtomic), Deadline: quote.ExpiresAt.Format(time.RFC3339Nano), ContractAddress: quote.ContractAddress, Method: quote.Method, diff --git a/backend/secretapi/app_test.go b/backend/secretapi/app_test.go index d95a32e..19b1204 100644 --- a/backend/secretapi/app_test.go +++ b/backend/secretapi/app_test.go @@ -88,6 +88,7 @@ func TestMembershipDistinctPayerProof(t *testing.T) { if quote.Tx["from"] != payerAddr { t.Fatalf("tx.from mismatch: %+v", quote.Tx) } + assertQuoteCostEnvelope(t, quote.CostEnvelope, quote.Currency, quote.Decimals, quote.AmountAtomic) } func TestMembershipCompanySponsorWithoutOwnerProof(t *testing.T) { @@ -1093,6 +1094,7 @@ func TestMarketplaceCheckoutBundlesMembershipAndMintsEntitlement(t *testing.T) { if quote.MerchantID != defaultMarketplaceMerchantID { t.Fatalf("expected default merchant id, got %+v", quote) } + assertQuoteCostEnvelope(t, quote.CostEnvelope, quote.Currency, quote.Decimals, quote.TotalAmountAtomic) confirm := postJSONExpect[marketplaceCheckoutConfirmResponse](t, a, "/marketplace/checkout/confirm", marketplaceCheckoutConfirmRequest{ QuoteID: quote.QuoteID, @@ -2025,6 +2027,49 @@ func mustKey(t *testing.T) *ecdsa.PrivateKey { return key } +func assertQuoteCostEnvelope(t *testing.T, got quoteCostEnvelope, wantCurrency string, wantDecimals int, wantAmountAtomic string) { + t.Helper() + if got.Version != quoteCostEnvelopeVersion { + t.Fatalf("cost envelope version mismatch: got=%s want=%s", got.Version, quoteCostEnvelopeVersion) + } + if !strings.EqualFold(strings.TrimSpace(got.CheckoutCurrency), strings.TrimSpace(wantCurrency)) { + t.Fatalf("cost envelope checkout currency mismatch: got=%s want=%s", got.CheckoutCurrency, wantCurrency) + } + if got.CheckoutDecimals != wantDecimals { + t.Fatalf("cost envelope decimals mismatch: got=%d want=%d", got.CheckoutDecimals, wantDecimals) + } + if strings.TrimSpace(got.CheckoutTotalAtomic) != strings.TrimSpace(wantAmountAtomic) { + t.Fatalf("cost envelope total atomic mismatch: got=%s want=%s", got.CheckoutTotalAtomic, wantAmountAtomic) + } + if strings.TrimSpace(got.CheckoutTotal) != strings.TrimSpace(formatAtomicAmount(wantAmountAtomic, wantDecimals)) { + t.Fatalf("cost envelope total mismatch: got=%s want=%s", got.CheckoutTotal, formatAtomicAmount(wantAmountAtomic, wantDecimals)) + } + if got.ProviderFeePolicy != quoteProviderFeePolicyEdutAbsorbed { + t.Fatalf("cost envelope provider fee policy mismatch: got=%s", got.ProviderFeePolicy) + } + if !got.ProviderFeeIncluded { + t.Fatalf("cost envelope provider fee should be included") + } + if got.ProviderFeeEstimateStatus != quoteProviderFeeEstimateStatusAbsorbed { + t.Fatalf("cost envelope provider fee status mismatch: got=%s", got.ProviderFeeEstimateStatus) + } + if got.ProviderFeeEstimateAtomic != "0" { + t.Fatalf("cost envelope provider fee estimate mismatch: got=%s", got.ProviderFeeEstimateAtomic) + } + if got.NetworkFeePolicy != quoteNetworkFeePolicyPayerPaysGas { + t.Fatalf("cost envelope network fee policy mismatch: got=%s", got.NetworkFeePolicy) + } + if got.NetworkFeeCurrency != quoteNetworkFeeDefaultCurrency { + t.Fatalf("cost envelope network fee currency mismatch: got=%s", got.NetworkFeeCurrency) + } + if got.NetworkFeeEstimateStatus != quoteNetworkFeeEstimateStatusWalletQuoted { + t.Fatalf("cost envelope network fee status mismatch: got=%s", got.NetworkFeeEstimateStatus) + } + if got.NetworkFeeEstimateAtomic != "0" { + t.Fatalf("cost envelope network fee estimate mismatch: got=%s", got.NetworkFeeEstimateAtomic) + } +} + func signTypedData(t *testing.T, key *ecdsa.PrivateKey, typedData apitypes.TypedData) string { t.Helper() hash, _, err := apitypes.TypedDataAndHash(typedData) diff --git a/backend/secretapi/config.go b/backend/secretapi/config.go index 34eb991..cd4581e 100644 --- a/backend/secretapi/config.go +++ b/backend/secretapi/config.go @@ -13,6 +13,7 @@ type Config struct { ListenAddr string DBPath string AllowedOrigin string + DeploymentClass string MemberPollIntervalSec int IntentTTL time.Duration QuoteTTL time.Duration @@ -45,6 +46,7 @@ func loadConfig() Config { ListenAddr: env("SECRET_API_LISTEN_ADDR", ":8080"), DBPath: env("SECRET_API_DB_PATH", "./secret.db"), AllowedOrigin: env("SECRET_API_ALLOWED_ORIGIN", "https://edut.ai"), + DeploymentClass: normalizeDeploymentClass(env("SECRET_API_DEPLOYMENT_CLASS", "development")), MemberPollIntervalSec: envInt("SECRET_API_MEMBER_POLL_INTERVAL_SECONDS", 30), IntentTTL: time.Duration(envInt("SECRET_API_INTENT_TTL_SECONDS", 900)) * time.Second, QuoteTTL: time.Duration(envInt("SECRET_API_QUOTE_TTL_SECONDS", 900)) * time.Second, @@ -62,7 +64,7 @@ func loadConfig() Config { MintAmountAtomic: env("SECRET_API_MINT_AMOUNT_ATOMIC", "100000000"), MintDecimals: envInt("SECRET_API_MINT_DECIMALS", 6), ChainRPCURL: env("SECRET_API_CHAIN_RPC_URL", ""), - RequireOnchainTxVerify: envBool("SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION", false), + RequireOnchainTxVerify: loadRequireOnchainTxVerify(), GovernanceRuntimeVersion: env("SECRET_API_GOV_RUNTIME_VERSION", "0.1.0"), GovernancePackageURL: env("SECRET_API_GOV_PACKAGE_URL", "https://cdn.edut.ai/governance/edutd-0.1.0.tar.gz"), GovernancePackageHash: strings.ToLower(env("SECRET_API_GOV_PACKAGE_HASH", "sha256:pending")), @@ -98,12 +100,24 @@ func (c Config) Validate() error { if c.WalletSessionTTL <= 0 { return fmt.Errorf("SECRET_API_WALLET_SESSION_TTL_SECONDS must be positive") } + if isProductionDeploymentClass(c.DeploymentClass) && !c.RequireOnchainTxVerify { + return fmt.Errorf("production deployment requires SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION=true") + } if c.RequireOnchainTxVerify && strings.TrimSpace(c.ChainRPCURL) == "" { return fmt.Errorf("SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION requires SECRET_API_CHAIN_RPC_URL") } return nil } +func loadRequireOnchainTxVerify() bool { + if raw, ok := os.LookupEnv("SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION"); ok { + if strings.TrimSpace(raw) != "" { + return parseBool(raw, false) + } + } + return isProductionDeploymentClass(env("SECRET_API_DEPLOYMENT_CLASS", "development")) +} + func env(key, fallback string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v @@ -128,6 +142,11 @@ func envBool(key string, fallback bool) bool { if raw == "" { return fallback } + return parseBool(raw, fallback) +} + +func parseBool(raw string, fallback bool) bool { + raw = strings.ToLower(strings.TrimSpace(raw)) switch raw { case "1", "true", "yes", "on": return true @@ -137,3 +156,21 @@ func envBool(key string, fallback bool) bool { return fallback } } + +func normalizeDeploymentClass(value string) string { + v := strings.ToLower(strings.TrimSpace(value)) + switch v { + case "prod", "production": + return "production" + case "staging", "stage": + return "staging" + case "dev", "development": + return "development" + default: + return "development" + } +} + +func isProductionDeploymentClass(value string) bool { + return normalizeDeploymentClass(value) == "production" +} diff --git a/backend/secretapi/config_test.go b/backend/secretapi/config_test.go index 08e2698..b4cec4d 100644 --- a/backend/secretapi/config_test.go +++ b/backend/secretapi/config_test.go @@ -2,8 +2,15 @@ package main import "testing" +func loadConfigIsolated(t *testing.T) Config { + t.Helper() + t.Setenv("SECRET_API_DEPLOYMENT_CLASS", "") + t.Setenv("SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION", "") + return loadConfig() +} + func TestConfigValidateAllowsDefaultLocalConfig(t *testing.T) { - cfg := loadConfig() + cfg := loadConfigIsolated(t) cfg.ChainID = 84532 cfg.RequireOnchainTxVerify = false cfg.ChainRPCURL = "" @@ -13,7 +20,7 @@ func TestConfigValidateAllowsDefaultLocalConfig(t *testing.T) { } func TestConfigValidateRejectsStrictVerificationWithoutRPC(t *testing.T) { - cfg := loadConfig() + cfg := loadConfigIsolated(t) cfg.RequireOnchainTxVerify = true cfg.ChainRPCURL = "" if err := cfg.Validate(); err == nil { @@ -22,7 +29,7 @@ func TestConfigValidateRejectsStrictVerificationWithoutRPC(t *testing.T) { } func TestConfigValidateRejectsNonPositiveChainID(t *testing.T) { - cfg := loadConfig() + cfg := loadConfigIsolated(t) cfg.ChainID = 0 if err := cfg.Validate(); err == nil { t.Fatalf("expected chain id validation failure") @@ -30,7 +37,7 @@ func TestConfigValidateRejectsNonPositiveChainID(t *testing.T) { } func TestConfigValidateAllowsETHWhenDecimalsMatch(t *testing.T) { - cfg := loadConfig() + cfg := loadConfigIsolated(t) cfg.MintCurrency = "ETH" cfg.MintDecimals = 18 cfg.MintAmountAtomic = "1" @@ -40,7 +47,7 @@ func TestConfigValidateAllowsETHWhenDecimalsMatch(t *testing.T) { } func TestConfigValidateRejectsETHWhenDecimalsMismatch(t *testing.T) { - cfg := loadConfig() + cfg := loadConfigIsolated(t) cfg.MintCurrency = "ETH" cfg.MintDecimals = 6 if err := cfg.Validate(); err == nil { @@ -49,7 +56,7 @@ func TestConfigValidateRejectsETHWhenDecimalsMismatch(t *testing.T) { } func TestConfigValidateRejectsUSDCWhenDecimalsMismatch(t *testing.T) { - cfg := loadConfig() + cfg := loadConfigIsolated(t) cfg.MintCurrency = "USDC" cfg.MintDecimals = 18 if err := cfg.Validate(); err == nil { @@ -58,7 +65,7 @@ func TestConfigValidateRejectsUSDCWhenDecimalsMismatch(t *testing.T) { } func TestConfigValidateRejectsInvalidMintAmount(t *testing.T) { - cfg := loadConfig() + cfg := loadConfigIsolated(t) cfg.MintAmountAtomic = "not-a-number" if err := cfg.Validate(); err == nil { t.Fatalf("expected mint amount validation failure") @@ -67,7 +74,7 @@ func TestConfigValidateRejectsInvalidMintAmount(t *testing.T) { func TestLoadConfigDefaultsWalletSessionRequired(t *testing.T) { t.Setenv("SECRET_API_REQUIRE_WALLET_SESSION", "") - cfg := loadConfig() + cfg := loadConfigIsolated(t) if !cfg.RequireWalletSession { t.Fatalf("expected wallet session to be required by default") } @@ -75,8 +82,36 @@ func TestLoadConfigDefaultsWalletSessionRequired(t *testing.T) { func TestLoadConfigWalletSessionCanBeDisabled(t *testing.T) { t.Setenv("SECRET_API_REQUIRE_WALLET_SESSION", "false") - cfg := loadConfig() + cfg := loadConfigIsolated(t) if cfg.RequireWalletSession { t.Fatalf("expected wallet session requirement to be disabled by env override") } } + +func TestLoadConfigDefaultsOnchainVerificationFalseInDevelopment(t *testing.T) { + t.Setenv("SECRET_API_DEPLOYMENT_CLASS", "development") + t.Setenv("SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION", "") + cfg := loadConfig() + if cfg.RequireOnchainTxVerify { + t.Fatalf("expected strict onchain verification to default false in development") + } +} + +func TestLoadConfigDefaultsOnchainVerificationTrueInProduction(t *testing.T) { + t.Setenv("SECRET_API_DEPLOYMENT_CLASS", "production") + t.Setenv("SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION", "") + cfg := loadConfig() + if !cfg.RequireOnchainTxVerify { + t.Fatalf("expected strict onchain verification to default true in production") + } +} + +func TestConfigValidateRejectsProductionWhenStrictVerificationDisabled(t *testing.T) { + cfg := loadConfigIsolated(t) + cfg.DeploymentClass = "production" + cfg.RequireOnchainTxVerify = false + cfg.ChainRPCURL = "https://example.invalid" + if err := cfg.Validate(); err == nil { + t.Fatalf("expected production config validation failure when strict onchain verification disabled") + } +} diff --git a/backend/secretapi/marketplace.go b/backend/secretapi/marketplace.go index 7c4a832..6a62192 100644 --- a/backend/secretapi/marketplace.go +++ b/backend/secretapi/marketplace.go @@ -50,11 +50,11 @@ func (a *app) marketplaceOffersForMerchant(merchantID string) []marketplaceOffer offers := []marketplaceOffer{ { MerchantID: defaultMarketplaceMerchantID, - OfferID: offerIDSoloCore, - IssuerID: defaultMarketplaceMerchantID, - Title: "EDUT Solo Core", - Summary: "Single-principal governance runtime for personal operations.", - Status: "active", + OfferID: offerIDSoloCore, + IssuerID: defaultMarketplaceMerchantID, + Title: "EDUT Solo Core", + Summary: "Single-principal governance runtime for personal operations.", + Status: "active", Pricing: marketplaceOfferPrice{ Currency: "USDC", AmountAtomic: marketplaceStandardOfferAtomic, @@ -78,11 +78,11 @@ func (a *app) marketplaceOffersForMerchant(merchantID string) []marketplaceOffer }, { MerchantID: defaultMarketplaceMerchantID, - OfferID: offerIDWorkspaceCore, - IssuerID: defaultMarketplaceMerchantID, - Title: "EDUT Workspace Core", - Summary: "Org-bound deterministic governance runtime for team operations.", - Status: "active", + 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, @@ -106,11 +106,11 @@ func (a *app) marketplaceOffersForMerchant(merchantID string) []marketplaceOffer }, { MerchantID: defaultMarketplaceMerchantID, - OfferID: offerIDWorkspaceAI, - IssuerID: defaultMarketplaceMerchantID, - Title: "EDUT Workspace AI Layer", - Summary: "AI reasoning layer for governed workspace operations.", - Status: "active", + 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, @@ -135,11 +135,11 @@ func (a *app) marketplaceOffersForMerchant(merchantID string) []marketplaceOffer }, { MerchantID: defaultMarketplaceMerchantID, - OfferID: offerIDWorkspaceLane24, - IssuerID: defaultMarketplaceMerchantID, - Title: "EDUT Workspace 24h Lane", - Summary: "Autonomous execution lane capacity for workspace queue throughput.", - Status: "active", + 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, @@ -163,11 +163,11 @@ func (a *app) marketplaceOffersForMerchant(merchantID string) []marketplaceOffer }, { MerchantID: defaultMarketplaceMerchantID, - OfferID: offerIDWorkspaceSovereign, - IssuerID: defaultMarketplaceMerchantID, - Title: "EDUT Workspace Sovereign Continuity", - Summary: "Workspace continuity profile for stronger local/offline operation.", - Status: "active", + 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, @@ -501,6 +501,7 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ TotalAmount: formatAtomicAmount(quote.TotalAmountAtomic, quote.Decimals), TotalAmountAtomic: quote.TotalAmountAtomic, Decimals: quote.Decimals, + CostEnvelope: newQuoteCostEnvelope(quote.Currency, quote.Decimals, quote.TotalAmountAtomic), MembershipActivationIncluded: quote.MembershipIncluded, LineItems: lineItems, PolicyHash: quote.PolicyHash, diff --git a/backend/secretapi/marketplace_models.go b/backend/secretapi/marketplace_models.go index 27b5bce..4624036 100644 --- a/backend/secretapi/marketplace_models.go +++ b/backend/secretapi/marketplace_models.go @@ -3,16 +3,16 @@ package main import "time" type marketplaceOffer struct { - MerchantID string `json:"merchant_id,omitempty"` - OfferID string `json:"offer_id"` - IssuerID string `json:"issuer_id"` - Title string `json:"title"` - Summary string `json:"summary,omitempty"` - Status string `json:"status"` - Pricing marketplaceOfferPrice `json:"pricing"` - Policies marketplaceOfferPolicy `json:"policies"` - ExecutionProfile marketplaceExecutionProfile `json:"execution_profile,omitempty"` - SortOrder int `json:"-"` + MerchantID string `json:"merchant_id,omitempty"` + OfferID string `json:"offer_id"` + IssuerID string `json:"issuer_id"` + Title string `json:"title"` + Summary string `json:"summary,omitempty"` + Status string `json:"status"` + Pricing marketplaceOfferPrice `json:"pricing"` + Policies marketplaceOfferPolicy `json:"policies"` + ExecutionProfile marketplaceExecutionProfile `json:"execution_profile,omitempty"` + SortOrder int `json:"-"` } type marketplaceOfferPrice struct { @@ -79,6 +79,7 @@ type marketplaceCheckoutQuoteResponse struct { TotalAmount string `json:"total_amount"` TotalAmountAtomic string `json:"total_amount_atomic"` Decimals int `json:"decimals"` + CostEnvelope quoteCostEnvelope `json:"cost_envelope"` MembershipActivationIncluded bool `json:"membership_activation_included"` LineItems []marketplaceQuoteLineItem `json:"line_items"` PolicyHash string `json:"policy_hash"` diff --git a/backend/secretapi/models.go b/backend/secretapi/models.go index 99df80d..23e035d 100644 --- a/backend/secretapi/models.go +++ b/backend/secretapi/models.go @@ -68,21 +68,22 @@ type membershipQuoteRequest struct { } type membershipQuoteResponse struct { - QuoteID string `json:"quote_id"` - ChainID int64 `json:"chain_id"` - Currency string `json:"currency"` - AmountAtomic string `json:"amount_atomic"` - Decimals int `json:"decimals"` - Deadline string `json:"deadline"` - ContractAddress string `json:"contract_address"` - Method string `json:"method"` - Calldata string `json:"calldata"` - Value string `json:"value"` - OwnerWallet string `json:"owner_wallet,omitempty"` - PayerWallet string `json:"payer_wallet,omitempty"` - SponsorshipMode string `json:"sponsorship_mode,omitempty"` - SponsorOrgRoot string `json:"sponsor_org_root_id,omitempty"` - Tx map[string]any `json:"tx"` + QuoteID string `json:"quote_id"` + ChainID int64 `json:"chain_id"` + Currency string `json:"currency"` + AmountAtomic string `json:"amount_atomic"` + Decimals int `json:"decimals"` + CostEnvelope quoteCostEnvelope `json:"cost_envelope"` + Deadline string `json:"deadline"` + ContractAddress string `json:"contract_address"` + Method string `json:"method"` + Calldata string `json:"calldata"` + Value string `json:"value"` + OwnerWallet string `json:"owner_wallet,omitempty"` + PayerWallet string `json:"payer_wallet,omitempty"` + SponsorshipMode string `json:"sponsorship_mode,omitempty"` + SponsorOrgRoot string `json:"sponsor_org_root_id,omitempty"` + Tx map[string]any `json:"tx"` } type membershipConfirmRequest struct { diff --git a/backend/secretapi/quote_cost.go b/backend/secretapi/quote_cost.go new file mode 100644 index 0000000..7621ee5 --- /dev/null +++ b/backend/secretapi/quote_cost.go @@ -0,0 +1,44 @@ +package main + +const ( + quoteCostEnvelopeVersion = "edut.quote_cost_envelope.v1" + quoteProviderFeePolicyEdutAbsorbed = "edut_absorbed" + quoteProviderFeeEstimateStatusAbsorbed = "absorbed_by_edut" + quoteNetworkFeePolicyPayerPaysGas = "payer_wallet_pays_chain_gas" + quoteNetworkFeeEstimateStatusWalletQuoted = "wallet_estimate_required" + quoteNetworkFeeDefaultCurrency = "ETH" +) + +type quoteCostEnvelope struct { + Version string `json:"version"` + CheckoutCurrency string `json:"checkout_currency"` + CheckoutDecimals int `json:"checkout_decimals"` + CheckoutTotalAtomic string `json:"checkout_total_atomic"` + CheckoutTotal string `json:"checkout_total"` + ProviderFeePolicy string `json:"provider_fee_policy"` + ProviderFeeIncluded bool `json:"provider_fee_included"` + ProviderFeeEstimateStatus string `json:"provider_fee_estimate_status"` + ProviderFeeEstimateAtomic string `json:"provider_fee_estimate_atomic"` + NetworkFeePolicy string `json:"network_fee_policy"` + NetworkFeeCurrency string `json:"network_fee_currency"` + NetworkFeeEstimateStatus string `json:"network_fee_estimate_status"` + NetworkFeeEstimateAtomic string `json:"network_fee_estimate_atomic"` +} + +func newQuoteCostEnvelope(checkoutCurrency string, checkoutDecimals int, checkoutTotalAtomic string) quoteCostEnvelope { + return quoteCostEnvelope{ + Version: quoteCostEnvelopeVersion, + CheckoutCurrency: checkoutCurrency, + CheckoutDecimals: checkoutDecimals, + CheckoutTotalAtomic: checkoutTotalAtomic, + CheckoutTotal: formatAtomicAmount(checkoutTotalAtomic, checkoutDecimals), + ProviderFeePolicy: quoteProviderFeePolicyEdutAbsorbed, + ProviderFeeIncluded: true, + ProviderFeeEstimateStatus: quoteProviderFeeEstimateStatusAbsorbed, + ProviderFeeEstimateAtomic: "0", + NetworkFeePolicy: quoteNetworkFeePolicyPayerPaysGas, + NetworkFeeCurrency: quoteNetworkFeeDefaultCurrency, + NetworkFeeEstimateStatus: quoteNetworkFeeEstimateStatusWalletQuoted, + NetworkFeeEstimateAtomic: "0", + } +} diff --git a/docs/api/examples/marketplace.examples.md b/docs/api/examples/marketplace.examples.md index d8ae9a9..480470e 100644 --- a/docs/api/examples/marketplace.examples.md +++ b/docs/api/examples/marketplace.examples.md @@ -70,6 +70,21 @@ Success (`200`): "total_amount": "1100.00", "total_amount_atomic": "1100000000", "decimals": 6, + "cost_envelope": { + "version": "edut.quote_cost_envelope.v1", + "checkout_currency": "USDC", + "checkout_decimals": 6, + "checkout_total_atomic": "1100000000", + "checkout_total": "1100", + "provider_fee_policy": "edut_absorbed", + "provider_fee_included": true, + "provider_fee_estimate_status": "absorbed_by_edut", + "provider_fee_estimate_atomic": "0", + "network_fee_policy": "payer_wallet_pays_chain_gas", + "network_fee_currency": "ETH", + "network_fee_estimate_status": "wallet_estimate_required", + "network_fee_estimate_atomic": "0" + }, "membership_activation_included": true, "line_items": [ { diff --git a/docs/api/examples/secret-system.examples.md b/docs/api/examples/secret-system.examples.md index 10632a6..c1f87d8 100644 --- a/docs/api/examples/secret-system.examples.md +++ b/docs/api/examples/secret-system.examples.md @@ -160,12 +160,26 @@ Success (`200`): "quote_id": "mq_01HZZX4F8VQXJ6A57R8P3SCB2W", "chain_id": 8453, "currency": "USDC", - "amount": "100.00", "amount_atomic": "100000000", "decimals": 6, + "cost_envelope": { + "version": "edut.quote_cost_envelope.v1", + "checkout_currency": "USDC", + "checkout_decimals": 6, + "checkout_total_atomic": "100000000", + "checkout_total": "100", + "provider_fee_policy": "edut_absorbed", + "provider_fee_included": true, + "provider_fee_estimate_status": "absorbed_by_edut", + "provider_fee_estimate_atomic": "0", + "network_fee_policy": "payer_wallet_pays_chain_gas", + "network_fee_currency": "ETH", + "network_fee_estimate_status": "wallet_estimate_required", + "network_fee_estimate_atomic": "0" + }, "deadline": "2026-02-17T07:36:12Z", "contract_address": "0x1111111111111111111111111111111111111111", - "method": "mintMembership", + "method": "mintMembership(address)", "calldata": "0xdeadbeef", "value": "0x0", "owner_wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5", diff --git a/docs/api/marketplace.openapi.yaml b/docs/api/marketplace.openapi.yaml index 07386cc..1a45033 100644 --- a/docs/api/marketplace.openapi.yaml +++ b/docs/api/marketplace.openapi.yaml @@ -222,7 +222,7 @@ components: description: If true, quote may bundle first-time membership fee into total. CheckoutQuoteResponse: type: object - required: [quote_id, wallet, offer_id, currency, amount_atomic, total_amount_atomic, policy_hash, expires_at] + required: [quote_id, wallet, offer_id, currency, amount_atomic, total_amount_atomic, decimals, cost_envelope, policy_hash, expires_at] properties: quote_id: type: string @@ -255,6 +255,8 @@ components: type: string decimals: type: integer + cost_envelope: + $ref: '#/components/schemas/QuoteCostEnvelope' membership_activation_included: type: boolean line_items: @@ -292,6 +294,55 @@ components: type: integer currency: type: string + QuoteCostEnvelope: + type: object + required: + - version + - checkout_currency + - checkout_decimals + - checkout_total_atomic + - checkout_total + - provider_fee_policy + - provider_fee_included + - provider_fee_estimate_status + - provider_fee_estimate_atomic + - network_fee_policy + - network_fee_currency + - network_fee_estimate_status + - network_fee_estimate_atomic + properties: + version: + type: string + enum: [edut.quote_cost_envelope.v1] + checkout_currency: + type: string + checkout_decimals: + type: integer + checkout_total_atomic: + type: string + checkout_total: + type: string + provider_fee_policy: + type: string + enum: [edut_absorbed] + provider_fee_included: + type: boolean + provider_fee_estimate_status: + type: string + enum: [absorbed_by_edut] + provider_fee_estimate_atomic: + type: string + network_fee_policy: + type: string + enum: [payer_wallet_pays_chain_gas] + network_fee_currency: + type: string + enum: [ETH] + network_fee_estimate_status: + type: string + enum: [wallet_estimate_required] + network_fee_estimate_atomic: + type: string CheckoutConfirmRequest: type: object required: [quote_id, wallet, offer_id, tx_hash, chain_id] diff --git a/docs/api/secret-system.openapi.yaml b/docs/api/secret-system.openapi.yaml index 2ef2eac..decefb2 100644 --- a/docs/api/secret-system.openapi.yaml +++ b/docs/api/secret-system.openapi.yaml @@ -355,7 +355,7 @@ components: type: string MembershipQuoteResponse: type: object - required: [quote_id, chain_id, currency, amount_atomic, deadline, contract_address] + required: [quote_id, chain_id, currency, amount_atomic, decimals, cost_envelope, deadline, contract_address] properties: quote_id: type: string @@ -364,12 +364,12 @@ components: currency: type: string enum: [USDC] - amount: - type: string amount_atomic: type: string decimals: type: integer + cost_envelope: + $ref: '#/components/schemas/QuoteCostEnvelope' deadline: type: string format: date-time @@ -393,6 +393,55 @@ components: tx: type: object additionalProperties: true + QuoteCostEnvelope: + type: object + required: + - version + - checkout_currency + - checkout_decimals + - checkout_total_atomic + - checkout_total + - provider_fee_policy + - provider_fee_included + - provider_fee_estimate_status + - provider_fee_estimate_atomic + - network_fee_policy + - network_fee_currency + - network_fee_estimate_status + - network_fee_estimate_atomic + properties: + version: + type: string + enum: [edut.quote_cost_envelope.v1] + checkout_currency: + type: string + checkout_decimals: + type: integer + checkout_total_atomic: + type: string + checkout_total: + type: string + provider_fee_policy: + type: string + enum: [edut_absorbed] + provider_fee_included: + type: boolean + provider_fee_estimate_status: + type: string + enum: [absorbed_by_edut] + provider_fee_estimate_atomic: + type: string + network_fee_policy: + type: string + enum: [payer_wallet_pays_chain_gas] + network_fee_currency: + type: string + enum: [ETH] + network_fee_estimate_status: + type: string + enum: [wallet_estimate_required] + network_fee_estimate_atomic: + type: string MembershipConfirmRequest: type: object required: [designation_code, quote_id, tx_hash, address, chain_id] diff --git a/docs/secret-system-spec.md b/docs/secret-system-spec.md index ec59e5f..845c017 100644 --- a/docs/secret-system-spec.md +++ b/docs/secret-system-spec.md @@ -259,11 +259,26 @@ Response: "quote_id": "mq_...", "chain_id": 8453, "currency": "USDC", - "amount": "100.00", "amount_atomic": "100000000", + "decimals": 6, + "cost_envelope": { + "version": "edut.quote_cost_envelope.v1", + "checkout_currency": "USDC", + "checkout_decimals": 6, + "checkout_total_atomic": "100000000", + "checkout_total": "100", + "provider_fee_policy": "edut_absorbed", + "provider_fee_included": true, + "provider_fee_estimate_status": "absorbed_by_edut", + "provider_fee_estimate_atomic": "0", + "network_fee_policy": "payer_wallet_pays_chain_gas", + "network_fee_currency": "ETH", + "network_fee_estimate_status": "wallet_estimate_required", + "network_fee_estimate_atomic": "0" + }, "deadline": "2026-02-17T07:36:12Z", "contract_address": "0x...", - "method": "mintMembership", + "method": "mintMembership(address)", "calldata": "0x..." } ``` diff --git a/public/index.html b/public/index.html index 9a33097..1dfe6cd 100644 --- a/public/index.html +++ b/public/index.html @@ -597,9 +597,14 @@ function formatAtomicAmount(atomicRaw, decimalsRaw) { function formatQuoteDisplay(quote) { if (!quote || typeof quote !== 'object') return null; - const currency = quote.currency ? String(quote.currency) : ''; + const envelope = (quote.cost_envelope && typeof quote.cost_envelope === 'object') ? quote.cost_envelope : null; + const currency = envelope && envelope.checkout_currency + ? String(envelope.checkout_currency) + : (quote.currency ? String(quote.currency) : ''); let amount = null; - if (quote.amount !== undefined && quote.amount !== null) { + if (envelope && envelope.checkout_total !== undefined && envelope.checkout_total !== null) { + amount = String(envelope.checkout_total); + } else if (quote.amount !== undefined && quote.amount !== null) { amount = String(quote.amount); } else if (quote.display_amount !== undefined && quote.display_amount !== null) { amount = String(quote.display_amount); diff --git a/public/store/index.html b/public/store/index.html index 423bbc7..c10ceb9 100644 --- a/public/store/index.html +++ b/public/store/index.html @@ -795,9 +795,12 @@ const quotePayload = await postJson('/marketplace/checkout/quote', payload); const quoteId = quotePayload.quote_id || 'unknown'; + const envelope = (quotePayload.cost_envelope && typeof quotePayload.cost_envelope === 'object') + ? quotePayload.cost_envelope + : null; + const currency = (envelope && envelope.checkout_currency) || quotePayload.currency || 'unknown'; const amount = quotePayload.amount || quotePayload.amount_atomic || 'unknown'; - const total = quotePayload.total_amount || quotePayload.total_amount_atomic || amount; - const currency = quotePayload.currency || 'unknown'; + const total = (envelope && envelope.checkout_total) || quotePayload.total_amount || quotePayload.total_amount_atomic || amount; const lines = Array.isArray(quotePayload.line_items) ? quotePayload.line_items : []; let breakdown = ''; if (lines.length > 0) { @@ -808,10 +811,16 @@ return '- ' + label + ': ' + value + ' ' + unit; }).join('\\n'); } + let feePolicy = ''; + if (envelope) { + feePolicy = '\\nFee policy: provider=' + (envelope.provider_fee_policy || 'unknown') + + ', network=' + (envelope.network_fee_policy || 'unknown') + '.'; + } setCheckoutLog( 'Quote ready for ' + state.selectedOfferId + ': ' + quoteId + ' (license ' + amount + ' ' + currency + ', total ' + total + ' ' + currency + ').' + - breakdown + breakdown + + feePolicy ); } catch (err) { setCheckoutLog('Quote request failed: ' + err.message + '. API wiring pending.');