W0: add deterministic quote cost envelope and docs sync
Some checks are pending
check / secretapi (push) Waiting to run

This commit is contained in:
Joshua 2026-02-19 18:02:30 -08:00
parent cd969480a0
commit c80b1db18b
16 changed files with 411 additions and 74 deletions

View File

@ -120,6 +120,16 @@ Policy gates:
1. Store checkout requires active membership. 1. Store checkout requires active membership.
2. Workspace admin install/support actions require `onramp_attested` assurance. 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 ## Key Environment Variables
### Core ### Core
@ -127,10 +137,14 @@ Policy gates:
- `SECRET_API_LISTEN_ADDR` (default `:8080`) - `SECRET_API_LISTEN_ADDR` (default `:8080`)
- `SECRET_API_DB_PATH` (default `./secret.db`) - `SECRET_API_DB_PATH` (default `./secret.db`)
- `SECRET_API_ALLOWED_ORIGIN` (default `https://edut.ai`) - `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_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 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) - `SECRET_API_ENTITLEMENT_CONTRACT` (optional; when set, marketplace quote emits purchase calldata for entitlement settlement contract)
### Membership ### Membership

View File

@ -470,6 +470,7 @@ func (a *app) handleMembershipQuote(w http.ResponseWriter, r *http.Request) {
Currency: quote.Currency, Currency: quote.Currency,
AmountAtomic: quote.AmountAtomic, AmountAtomic: quote.AmountAtomic,
Decimals: quote.Decimals, Decimals: quote.Decimals,
CostEnvelope: newQuoteCostEnvelope(quote.Currency, quote.Decimals, quote.AmountAtomic),
Deadline: quote.ExpiresAt.Format(time.RFC3339Nano), Deadline: quote.ExpiresAt.Format(time.RFC3339Nano),
ContractAddress: quote.ContractAddress, ContractAddress: quote.ContractAddress,
Method: quote.Method, Method: quote.Method,

View File

@ -88,6 +88,7 @@ func TestMembershipDistinctPayerProof(t *testing.T) {
if quote.Tx["from"] != payerAddr { if quote.Tx["from"] != payerAddr {
t.Fatalf("tx.from mismatch: %+v", quote.Tx) t.Fatalf("tx.from mismatch: %+v", quote.Tx)
} }
assertQuoteCostEnvelope(t, quote.CostEnvelope, quote.Currency, quote.Decimals, quote.AmountAtomic)
} }
func TestMembershipCompanySponsorWithoutOwnerProof(t *testing.T) { func TestMembershipCompanySponsorWithoutOwnerProof(t *testing.T) {
@ -1093,6 +1094,7 @@ func TestMarketplaceCheckoutBundlesMembershipAndMintsEntitlement(t *testing.T) {
if quote.MerchantID != defaultMarketplaceMerchantID { if quote.MerchantID != defaultMarketplaceMerchantID {
t.Fatalf("expected default merchant id, got %+v", quote) 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{ confirm := postJSONExpect[marketplaceCheckoutConfirmResponse](t, a, "/marketplace/checkout/confirm", marketplaceCheckoutConfirmRequest{
QuoteID: quote.QuoteID, QuoteID: quote.QuoteID,
@ -2025,6 +2027,49 @@ func mustKey(t *testing.T) *ecdsa.PrivateKey {
return key 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 { func signTypedData(t *testing.T, key *ecdsa.PrivateKey, typedData apitypes.TypedData) string {
t.Helper() t.Helper()
hash, _, err := apitypes.TypedDataAndHash(typedData) hash, _, err := apitypes.TypedDataAndHash(typedData)

View File

@ -13,6 +13,7 @@ type Config struct {
ListenAddr string ListenAddr string
DBPath string DBPath string
AllowedOrigin string AllowedOrigin string
DeploymentClass string
MemberPollIntervalSec int MemberPollIntervalSec int
IntentTTL time.Duration IntentTTL time.Duration
QuoteTTL time.Duration QuoteTTL time.Duration
@ -45,6 +46,7 @@ func loadConfig() Config {
ListenAddr: env("SECRET_API_LISTEN_ADDR", ":8080"), ListenAddr: env("SECRET_API_LISTEN_ADDR", ":8080"),
DBPath: env("SECRET_API_DB_PATH", "./secret.db"), DBPath: env("SECRET_API_DB_PATH", "./secret.db"),
AllowedOrigin: env("SECRET_API_ALLOWED_ORIGIN", "https://edut.ai"), 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), MemberPollIntervalSec: envInt("SECRET_API_MEMBER_POLL_INTERVAL_SECONDS", 30),
IntentTTL: time.Duration(envInt("SECRET_API_INTENT_TTL_SECONDS", 900)) * time.Second, IntentTTL: time.Duration(envInt("SECRET_API_INTENT_TTL_SECONDS", 900)) * time.Second,
QuoteTTL: time.Duration(envInt("SECRET_API_QUOTE_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"), MintAmountAtomic: env("SECRET_API_MINT_AMOUNT_ATOMIC", "100000000"),
MintDecimals: envInt("SECRET_API_MINT_DECIMALS", 6), MintDecimals: envInt("SECRET_API_MINT_DECIMALS", 6),
ChainRPCURL: env("SECRET_API_CHAIN_RPC_URL", ""), 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"), 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"), 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")), GovernancePackageHash: strings.ToLower(env("SECRET_API_GOV_PACKAGE_HASH", "sha256:pending")),
@ -98,12 +100,24 @@ func (c Config) Validate() error {
if c.WalletSessionTTL <= 0 { if c.WalletSessionTTL <= 0 {
return fmt.Errorf("SECRET_API_WALLET_SESSION_TTL_SECONDS must be positive") 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) == "" { if c.RequireOnchainTxVerify && strings.TrimSpace(c.ChainRPCURL) == "" {
return fmt.Errorf("SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION requires SECRET_API_CHAIN_RPC_URL") return fmt.Errorf("SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION requires SECRET_API_CHAIN_RPC_URL")
} }
return nil 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 { func env(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" { if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v return v
@ -128,6 +142,11 @@ func envBool(key string, fallback bool) bool {
if raw == "" { if raw == "" {
return fallback return fallback
} }
return parseBool(raw, fallback)
}
func parseBool(raw string, fallback bool) bool {
raw = strings.ToLower(strings.TrimSpace(raw))
switch raw { switch raw {
case "1", "true", "yes", "on": case "1", "true", "yes", "on":
return true return true
@ -137,3 +156,21 @@ func envBool(key string, fallback bool) bool {
return fallback 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"
}

View File

@ -2,8 +2,15 @@ package main
import "testing" 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) { func TestConfigValidateAllowsDefaultLocalConfig(t *testing.T) {
cfg := loadConfig() cfg := loadConfigIsolated(t)
cfg.ChainID = 84532 cfg.ChainID = 84532
cfg.RequireOnchainTxVerify = false cfg.RequireOnchainTxVerify = false
cfg.ChainRPCURL = "" cfg.ChainRPCURL = ""
@ -13,7 +20,7 @@ func TestConfigValidateAllowsDefaultLocalConfig(t *testing.T) {
} }
func TestConfigValidateRejectsStrictVerificationWithoutRPC(t *testing.T) { func TestConfigValidateRejectsStrictVerificationWithoutRPC(t *testing.T) {
cfg := loadConfig() cfg := loadConfigIsolated(t)
cfg.RequireOnchainTxVerify = true cfg.RequireOnchainTxVerify = true
cfg.ChainRPCURL = "" cfg.ChainRPCURL = ""
if err := cfg.Validate(); err == nil { if err := cfg.Validate(); err == nil {
@ -22,7 +29,7 @@ func TestConfigValidateRejectsStrictVerificationWithoutRPC(t *testing.T) {
} }
func TestConfigValidateRejectsNonPositiveChainID(t *testing.T) { func TestConfigValidateRejectsNonPositiveChainID(t *testing.T) {
cfg := loadConfig() cfg := loadConfigIsolated(t)
cfg.ChainID = 0 cfg.ChainID = 0
if err := cfg.Validate(); err == nil { if err := cfg.Validate(); err == nil {
t.Fatalf("expected chain id validation failure") t.Fatalf("expected chain id validation failure")
@ -30,7 +37,7 @@ func TestConfigValidateRejectsNonPositiveChainID(t *testing.T) {
} }
func TestConfigValidateAllowsETHWhenDecimalsMatch(t *testing.T) { func TestConfigValidateAllowsETHWhenDecimalsMatch(t *testing.T) {
cfg := loadConfig() cfg := loadConfigIsolated(t)
cfg.MintCurrency = "ETH" cfg.MintCurrency = "ETH"
cfg.MintDecimals = 18 cfg.MintDecimals = 18
cfg.MintAmountAtomic = "1" cfg.MintAmountAtomic = "1"
@ -40,7 +47,7 @@ func TestConfigValidateAllowsETHWhenDecimalsMatch(t *testing.T) {
} }
func TestConfigValidateRejectsETHWhenDecimalsMismatch(t *testing.T) { func TestConfigValidateRejectsETHWhenDecimalsMismatch(t *testing.T) {
cfg := loadConfig() cfg := loadConfigIsolated(t)
cfg.MintCurrency = "ETH" cfg.MintCurrency = "ETH"
cfg.MintDecimals = 6 cfg.MintDecimals = 6
if err := cfg.Validate(); err == nil { if err := cfg.Validate(); err == nil {
@ -49,7 +56,7 @@ func TestConfigValidateRejectsETHWhenDecimalsMismatch(t *testing.T) {
} }
func TestConfigValidateRejectsUSDCWhenDecimalsMismatch(t *testing.T) { func TestConfigValidateRejectsUSDCWhenDecimalsMismatch(t *testing.T) {
cfg := loadConfig() cfg := loadConfigIsolated(t)
cfg.MintCurrency = "USDC" cfg.MintCurrency = "USDC"
cfg.MintDecimals = 18 cfg.MintDecimals = 18
if err := cfg.Validate(); err == nil { if err := cfg.Validate(); err == nil {
@ -58,7 +65,7 @@ func TestConfigValidateRejectsUSDCWhenDecimalsMismatch(t *testing.T) {
} }
func TestConfigValidateRejectsInvalidMintAmount(t *testing.T) { func TestConfigValidateRejectsInvalidMintAmount(t *testing.T) {
cfg := loadConfig() cfg := loadConfigIsolated(t)
cfg.MintAmountAtomic = "not-a-number" cfg.MintAmountAtomic = "not-a-number"
if err := cfg.Validate(); err == nil { if err := cfg.Validate(); err == nil {
t.Fatalf("expected mint amount validation failure") t.Fatalf("expected mint amount validation failure")
@ -67,7 +74,7 @@ func TestConfigValidateRejectsInvalidMintAmount(t *testing.T) {
func TestLoadConfigDefaultsWalletSessionRequired(t *testing.T) { func TestLoadConfigDefaultsWalletSessionRequired(t *testing.T) {
t.Setenv("SECRET_API_REQUIRE_WALLET_SESSION", "") t.Setenv("SECRET_API_REQUIRE_WALLET_SESSION", "")
cfg := loadConfig() cfg := loadConfigIsolated(t)
if !cfg.RequireWalletSession { if !cfg.RequireWalletSession {
t.Fatalf("expected wallet session to be required by default") t.Fatalf("expected wallet session to be required by default")
} }
@ -75,8 +82,36 @@ func TestLoadConfigDefaultsWalletSessionRequired(t *testing.T) {
func TestLoadConfigWalletSessionCanBeDisabled(t *testing.T) { func TestLoadConfigWalletSessionCanBeDisabled(t *testing.T) {
t.Setenv("SECRET_API_REQUIRE_WALLET_SESSION", "false") t.Setenv("SECRET_API_REQUIRE_WALLET_SESSION", "false")
cfg := loadConfig() cfg := loadConfigIsolated(t)
if cfg.RequireWalletSession { if cfg.RequireWalletSession {
t.Fatalf("expected wallet session requirement to be disabled by env override") 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")
}
}

View File

@ -501,6 +501,7 @@ func (a *app) handleMarketplaceCheckoutQuote(w http.ResponseWriter, r *http.Requ
TotalAmount: formatAtomicAmount(quote.TotalAmountAtomic, quote.Decimals), TotalAmount: formatAtomicAmount(quote.TotalAmountAtomic, quote.Decimals),
TotalAmountAtomic: quote.TotalAmountAtomic, TotalAmountAtomic: quote.TotalAmountAtomic,
Decimals: quote.Decimals, Decimals: quote.Decimals,
CostEnvelope: newQuoteCostEnvelope(quote.Currency, quote.Decimals, quote.TotalAmountAtomic),
MembershipActivationIncluded: quote.MembershipIncluded, MembershipActivationIncluded: quote.MembershipIncluded,
LineItems: lineItems, LineItems: lineItems,
PolicyHash: quote.PolicyHash, PolicyHash: quote.PolicyHash,

View File

@ -79,6 +79,7 @@ type marketplaceCheckoutQuoteResponse struct {
TotalAmount string `json:"total_amount"` TotalAmount string `json:"total_amount"`
TotalAmountAtomic string `json:"total_amount_atomic"` TotalAmountAtomic string `json:"total_amount_atomic"`
Decimals int `json:"decimals"` Decimals int `json:"decimals"`
CostEnvelope quoteCostEnvelope `json:"cost_envelope"`
MembershipActivationIncluded bool `json:"membership_activation_included"` MembershipActivationIncluded bool `json:"membership_activation_included"`
LineItems []marketplaceQuoteLineItem `json:"line_items"` LineItems []marketplaceQuoteLineItem `json:"line_items"`
PolicyHash string `json:"policy_hash"` PolicyHash string `json:"policy_hash"`

View File

@ -73,6 +73,7 @@ type membershipQuoteResponse struct {
Currency string `json:"currency"` Currency string `json:"currency"`
AmountAtomic string `json:"amount_atomic"` AmountAtomic string `json:"amount_atomic"`
Decimals int `json:"decimals"` Decimals int `json:"decimals"`
CostEnvelope quoteCostEnvelope `json:"cost_envelope"`
Deadline string `json:"deadline"` Deadline string `json:"deadline"`
ContractAddress string `json:"contract_address"` ContractAddress string `json:"contract_address"`
Method string `json:"method"` Method string `json:"method"`

View File

@ -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",
}
}

View File

@ -70,6 +70,21 @@ Success (`200`):
"total_amount": "1100.00", "total_amount": "1100.00",
"total_amount_atomic": "1100000000", "total_amount_atomic": "1100000000",
"decimals": 6, "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, "membership_activation_included": true,
"line_items": [ "line_items": [
{ {

View File

@ -160,12 +160,26 @@ Success (`200`):
"quote_id": "mq_01HZZX4F8VQXJ6A57R8P3SCB2W", "quote_id": "mq_01HZZX4F8VQXJ6A57R8P3SCB2W",
"chain_id": 8453, "chain_id": 8453,
"currency": "USDC", "currency": "USDC",
"amount": "100.00",
"amount_atomic": "100000000", "amount_atomic": "100000000",
"decimals": 6, "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", "deadline": "2026-02-17T07:36:12Z",
"contract_address": "0x1111111111111111111111111111111111111111", "contract_address": "0x1111111111111111111111111111111111111111",
"method": "mintMembership", "method": "mintMembership(address)",
"calldata": "0xdeadbeef", "calldata": "0xdeadbeef",
"value": "0x0", "value": "0x0",
"owner_wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5", "owner_wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",

View File

@ -222,7 +222,7 @@ components:
description: If true, quote may bundle first-time membership fee into total. description: If true, quote may bundle first-time membership fee into total.
CheckoutQuoteResponse: CheckoutQuoteResponse:
type: object 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: properties:
quote_id: quote_id:
type: string type: string
@ -255,6 +255,8 @@ components:
type: string type: string
decimals: decimals:
type: integer type: integer
cost_envelope:
$ref: '#/components/schemas/QuoteCostEnvelope'
membership_activation_included: membership_activation_included:
type: boolean type: boolean
line_items: line_items:
@ -292,6 +294,55 @@ components:
type: integer type: integer
currency: currency:
type: string 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: CheckoutConfirmRequest:
type: object type: object
required: [quote_id, wallet, offer_id, tx_hash, chain_id] required: [quote_id, wallet, offer_id, tx_hash, chain_id]

View File

@ -355,7 +355,7 @@ components:
type: string type: string
MembershipQuoteResponse: MembershipQuoteResponse:
type: object 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: properties:
quote_id: quote_id:
type: string type: string
@ -364,12 +364,12 @@ components:
currency: currency:
type: string type: string
enum: [USDC] enum: [USDC]
amount:
type: string
amount_atomic: amount_atomic:
type: string type: string
decimals: decimals:
type: integer type: integer
cost_envelope:
$ref: '#/components/schemas/QuoteCostEnvelope'
deadline: deadline:
type: string type: string
format: date-time format: date-time
@ -393,6 +393,55 @@ components:
tx: tx:
type: object type: object
additionalProperties: true 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: MembershipConfirmRequest:
type: object type: object
required: [designation_code, quote_id, tx_hash, address, chain_id] required: [designation_code, quote_id, tx_hash, address, chain_id]

View File

@ -259,11 +259,26 @@ Response:
"quote_id": "mq_...", "quote_id": "mq_...",
"chain_id": 8453, "chain_id": 8453,
"currency": "USDC", "currency": "USDC",
"amount": "100.00",
"amount_atomic": "100000000", "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", "deadline": "2026-02-17T07:36:12Z",
"contract_address": "0x...", "contract_address": "0x...",
"method": "mintMembership", "method": "mintMembership(address)",
"calldata": "0x..." "calldata": "0x..."
} }
``` ```

View File

@ -597,9 +597,14 @@ function formatAtomicAmount(atomicRaw, decimalsRaw) {
function formatQuoteDisplay(quote) { function formatQuoteDisplay(quote) {
if (!quote || typeof quote !== 'object') return null; 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; 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); amount = String(quote.amount);
} else if (quote.display_amount !== undefined && quote.display_amount !== null) { } else if (quote.display_amount !== undefined && quote.display_amount !== null) {
amount = String(quote.display_amount); amount = String(quote.display_amount);

View File

@ -795,9 +795,12 @@
const quotePayload = await postJson('/marketplace/checkout/quote', payload); const quotePayload = await postJson('/marketplace/checkout/quote', payload);
const quoteId = quotePayload.quote_id || 'unknown'; 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 amount = quotePayload.amount || quotePayload.amount_atomic || 'unknown';
const total = quotePayload.total_amount || quotePayload.total_amount_atomic || amount; const total = (envelope && envelope.checkout_total) || quotePayload.total_amount || quotePayload.total_amount_atomic || amount;
const currency = quotePayload.currency || 'unknown';
const lines = Array.isArray(quotePayload.line_items) ? quotePayload.line_items : []; const lines = Array.isArray(quotePayload.line_items) ? quotePayload.line_items : [];
let breakdown = ''; let breakdown = '';
if (lines.length > 0) { if (lines.length > 0) {
@ -808,10 +811,16 @@
return '- ' + label + ': ' + value + ' ' + unit; return '- ' + label + ': ' + value + ' ' + unit;
}).join('\\n'); }).join('\\n');
} }
let feePolicy = '';
if (envelope) {
feePolicy = '\\nFee policy: provider=' + (envelope.provider_fee_policy || 'unknown') +
', network=' + (envelope.network_fee_policy || 'unknown') + '.';
}
setCheckoutLog( setCheckoutLog(
'Quote ready for ' + state.selectedOfferId + ': ' + quoteId + 'Quote ready for ' + state.selectedOfferId + ': ' + quoteId +
' (license ' + amount + ' ' + currency + ', total ' + total + ' ' + currency + ').' + ' (license ' + amount + ' ' + currency + ', total ' + total + ' ' + currency + ').' +
breakdown breakdown +
feePolicy
); );
} catch (err) { } catch (err) {
setCheckoutLog('Quote request failed: ' + err.message + '. API wiring pending.'); setCheckoutLog('Quote request failed: ' + err.message + '. API wiring pending.');