Add strict on-chain verification mode for membership confirmations

This commit is contained in:
Joshua 2026-02-18 07:07:13 -08:00
parent 70c2a4fe8f
commit a03eaaa493
4 changed files with 75 additions and 0 deletions

View File

@ -72,6 +72,7 @@ Company-first sponsor path is also supported:
- `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 fails closed without chain receipt verification)
### Membership ### Membership

View File

@ -404,6 +404,10 @@ func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "quote context mismatch") writeError(w, http.StatusBadRequest, "quote context mismatch")
return 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 { if err := verifyMintedOnChain(context.Background(), a.cfg, req.TxHash, address); err != nil {
writeError(w, http.StatusConflict, fmt.Sprintf("tx verification pending/failed: %v", err)) writeError(w, http.StatusConflict, fmt.Sprintf("tx verification pending/failed: %v", err))

View File

@ -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) { func TestGovernanceInstallOwnerOnlyAndConfirm(t *testing.T) {
a, cfg, cleanup := newTestApp(t) a, cfg, cleanup := newTestApp(t)
defer cleanup() defer cleanup()

View File

@ -25,6 +25,7 @@ type Config struct {
MintAmountAtomic string MintAmountAtomic string
MintDecimals int MintDecimals int
ChainRPCURL string ChainRPCURL string
RequireOnchainTxVerify bool
GovernanceRuntimeVersion string GovernanceRuntimeVersion string
GovernancePackageURL string GovernancePackageURL string
GovernancePackageHash string GovernancePackageHash string
@ -53,6 +54,7 @@ func loadConfig() Config {
MintAmountAtomic: env("SECRET_API_MINT_AMOUNT_ATOMIC", "5000000000000000"), MintAmountAtomic: env("SECRET_API_MINT_AMOUNT_ATOMIC", "5000000000000000"),
MintDecimals: envInt("SECRET_API_MINT_DECIMALS", 18), MintDecimals: envInt("SECRET_API_MINT_DECIMALS", 18),
ChainRPCURL: env("SECRET_API_CHAIN_RPC_URL", ""), 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"), 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")),
@ -81,3 +83,18 @@ func envInt(key string, fallback int) int {
} }
return value 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
}
}