diff --git a/backend/secretapi/README.md b/backend/secretapi/README.md index 18158a4..8bcf0c3 100644 --- a/backend/secretapi/README.md +++ b/backend/secretapi/README.md @@ -72,6 +72,7 @@ Company-first sponsor path is also supported: - `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 fails closed without chain receipt verification) ### Membership diff --git a/backend/secretapi/app.go b/backend/secretapi/app.go index bacab20..f061551 100644 --- a/backend/secretapi/app.go +++ b/backend/secretapi/app.go @@ -404,6 +404,10 @@ func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "quote context mismatch") return } + if a.cfg.RequireOnchainTxVerify && strings.TrimSpace(a.cfg.ChainRPCURL) == "" { + writeErrorCode(w, http.StatusServiceUnavailable, "chain_verification_unavailable", "chain rpc required for membership confirmation") + return + } if err := verifyMintedOnChain(context.Background(), a.cfg, req.TxHash, address); err != nil { writeError(w, http.StatusConflict, fmt.Sprintf("tx verification pending/failed: %v", err)) diff --git a/backend/secretapi/app_test.go b/backend/secretapi/app_test.go index 59a5afa..ce8c928 100644 --- a/backend/secretapi/app_test.go +++ b/backend/secretapi/app_test.go @@ -150,6 +150,59 @@ func TestMembershipCompanySponsorWithoutOwnerProof(t *testing.T) { } } +func TestMembershipConfirmRequiresChainRPCWhenStrictVerificationEnabled(t *testing.T) { + a, cfg, cleanup := newTestApp(t) + defer cleanup() + a.cfg.RequireOnchainTxVerify = true + a.cfg.ChainRPCURL = "" + + ownerKey := mustKey(t) + ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex()) + + intentRes := postJSONExpect[tWalletIntentResponse](t, a, "/secret/wallet/intent", walletIntentRequest{ + Address: ownerAddr, + Origin: "https://edut.ai", + Locale: "en", + ChainID: cfg.ChainID, + }, http.StatusOK) + + issuedAt, err := time.Parse(time.RFC3339Nano, intentRes.IssuedAt) + if err != nil { + t.Fatalf("parse issued_at: %v", err) + } + td := buildTypedData(cfg, designationRecord{ + Code: intentRes.DesignationCode, + DisplayToken: intentRes.DisplayToken, + Nonce: intentRes.Nonce, + IssuedAt: issuedAt, + Origin: "https://edut.ai", + }) + sig := signTypedData(t, ownerKey, td) + verifyRes := postJSONExpect[tWalletVerifyResponse](t, a, "/secret/wallet/verify", walletVerifyRequest{ + IntentID: intentRes.IntentID, + Address: ownerAddr, + ChainID: cfg.ChainID, + Signature: sig, + }, http.StatusOK) + + quote := postJSONExpect[membershipQuoteResponse](t, a, "/secret/membership/quote", membershipQuoteRequest{ + DesignationCode: verifyRes.DesignationCode, + Address: ownerAddr, + ChainID: cfg.ChainID, + }, http.StatusOK) + + fail := postJSONExpect[map[string]string](t, a, "/secret/membership/confirm", membershipConfirmRequest{ + DesignationCode: verifyRes.DesignationCode, + QuoteID: quote.QuoteID, + TxHash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Address: ownerAddr, + ChainID: cfg.ChainID, + }, http.StatusServiceUnavailable) + if fail["code"] != "chain_verification_unavailable" { + t.Fatalf("expected chain_verification_unavailable code, got %+v", fail) + } +} + func TestGovernanceInstallOwnerOnlyAndConfirm(t *testing.T) { a, cfg, cleanup := newTestApp(t) defer cleanup() diff --git a/backend/secretapi/config.go b/backend/secretapi/config.go index 20557ca..47524c2 100644 --- a/backend/secretapi/config.go +++ b/backend/secretapi/config.go @@ -25,6 +25,7 @@ type Config struct { MintAmountAtomic string MintDecimals int ChainRPCURL string + RequireOnchainTxVerify bool GovernanceRuntimeVersion string GovernancePackageURL string GovernancePackageHash string @@ -53,6 +54,7 @@ func loadConfig() Config { MintAmountAtomic: env("SECRET_API_MINT_AMOUNT_ATOMIC", "5000000000000000"), MintDecimals: envInt("SECRET_API_MINT_DECIMALS", 18), ChainRPCURL: env("SECRET_API_CHAIN_RPC_URL", ""), + RequireOnchainTxVerify: envBool("SECRET_API_REQUIRE_ONCHAIN_TX_VERIFICATION", false), 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")), @@ -81,3 +83,18 @@ func envInt(key string, fallback int) int { } return value } + +func envBool(key string, fallback bool) bool { + raw := strings.ToLower(strings.TrimSpace(os.Getenv(key))) + if raw == "" { + return fallback + } + switch raw { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return fallback + } +}