Harden membership confirm with quote tx matching

This commit is contained in:
Joshua 2026-02-18 19:50:50 -08:00
parent 66d5ea07cd
commit 04f2de2ccf
2 changed files with 75 additions and 0 deletions

View File

@ -415,6 +415,10 @@ func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, "quote expired") writeError(w, http.StatusConflict, "quote expired")
return 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 { if !strings.EqualFold(quote.Address, address) || !strings.EqualFold(quote.DesignationCode, req.DesignationCode) || quote.ChainID != req.ChainID {
writeError(w, http.StatusBadRequest, "quote context mismatch") writeError(w, http.StatusBadRequest, "quote context mismatch")
return return
@ -424,6 +428,22 @@ func (a *app) handleMembershipConfirm(w http.ResponseWriter, r *http.Request) {
return 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 { 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))
return return

View File

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