diff --git a/backend/secretapi/app.go b/backend/secretapi/app.go index 243068c..d8bb1fc 100644 --- a/backend/secretapi/app.go +++ b/backend/secretapi/app.go @@ -415,6 +415,10 @@ func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusConflict, "quote expired") return } + if quote.ConfirmedAt != nil { + writeError(w, http.StatusConflict, "quote already confirmed") + return + } if !strings.EqualFold(quote.Address, address) || !strings.EqualFold(quote.DesignationCode, req.DesignationCode) || quote.ChainID != req.ChainID { writeError(w, http.StatusBadRequest, "quote context mismatch") return @@ -424,6 +428,22 @@ func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) { return } + if strings.TrimSpace(quote.PayerAddress) == "" { + quote.PayerAddress = quote.Address + } + if err := verifyTransactionCallOnChain( + context.Background(), + a.cfg, + req.TxHash, + quote.PayerAddress, + quote.ContractAddress, + quote.Calldata, + quote.ValueHex, + ); err != nil { + writeError(w, http.StatusConflict, fmt.Sprintf("tx verification pending/failed: %v", err)) + 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)) return diff --git a/backend/secretapi/app_test.go b/backend/secretapi/app_test.go index e1a882c..37a6da8 100644 --- a/backend/secretapi/app_test.go +++ b/backend/secretapi/app_test.go @@ -329,6 +329,61 @@ func TestMembershipConfirmAcceptsOnrampAttestationAssurance(t *testing.T) { } } +func TestMembershipConfirmRejectsAlreadyConfirmedQuote(t *testing.T) { + a, cfg, cleanup := newTestApp(t) + defer cleanup() + + 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) + + _ = postJSONExpect[membershipConfirmResponse](t, a, "/secret/membership/confirm", membershipConfirmRequest{ + DesignationCode: verifyRes.DesignationCode, + QuoteID: quote.QuoteID, + TxHash: "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + Address: ownerAddr, + ChainID: cfg.ChainID, + }, http.StatusOK) + + _ = postJSONExpect[map[string]string](t, a, "/secret/membership/confirm", membershipConfirmRequest{ + DesignationCode: verifyRes.DesignationCode, + QuoteID: quote.QuoteID, + TxHash: "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + Address: ownerAddr, + ChainID: cfg.ChainID, + }, http.StatusConflict) +} + func TestGovernanceInstallOwnerOnlyAndConfirm(t *testing.T) { a, cfg, cleanup := newTestApp(t) defer cleanup()