Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# MicroAI Paygate Context

## Domain Vocabulary

**Payment Context**
The gateway-issued x402 signing payload for one paid AI request. It contains the recipient, token, amount, chain ID, nonce, and timestamp that the client signs with EIP-712 typed data.

**Signed Retry**
The client request sent after a `402 Payment Required` challenge. It repeats the original AI request and includes `X-402-Signature`, `X-402-Nonce`, and `X-402-Timestamp` headers from the Payment Context.

**Verifier Result**
The Rust verifier's answer for a Signed Retry. A valid result includes the recovered wallet address; an invalid result includes a machine-readable `error_code` such as `invalid_signature`, `nonce_already_used`, or `chain_id_mismatch`.

**Paid Request**
A gateway request that has passed x402 verification and can either call the AI provider or serve an already-cached AI result. A Paid Request is the point where receipt generation becomes valid.

**Receipt**
The gateway-signed proof produced after a successful Paid Request. It records payment terms, payer, endpoint, request hash, response hash, and the gateway server public key.

**Receipt Store**
The persistence module for signed receipts. Redis is the default adapter; memory storage is used for local quick starts and tests.

**Response Cache**
The optional Redis-backed cache for AI summaries. A cache hit still requires a valid Signed Retry before the cached result can be returned.
40 changes: 5 additions & 35 deletions gateway/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"log"
"net/http"
"os"
"strconv"
"sync"
"time"

Expand Down Expand Up @@ -118,51 +117,22 @@ func CacheMiddleware() gin.HandlerFunc {
log.Printf("Cache HIT: %s", cacheKey)

// Cache HIT! -> Verify Payment *BEFORE* serving
// verifyPayment creates its own timeout context, so pass request context directly
timestampStr := c.GetHeader("X-402-Timestamp")
if timestampStr == "" {
respondError(c, 400, "invalid_timestamp", fmt.Errorf("missing X-402-Timestamp header"))
c.Abort()
return
}
timestamp, err := strconv.ParseUint(timestampStr, 10, 64)
if err != nil || timestamp == 0 {
respondError(c, 400, "invalid_timestamp", fmt.Errorf("invalid X-402-Timestamp header"))
c.Abort()
return
}
verifyResp, paymentCtx, err := verifyPayment(c.Request.Context(), signature, nonce, timestamp)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
respondError(c, 504, "verifier_timeout", err)
} else {
respondError(c, 502, "verification_unavailable", err)
}
c.Abort()
return
}

if !verifyResp.IsValid {
respondVerificationFailure(c, verifyResp)
c.Abort()
return
}
if verifyResp.RecoveredAddress == "" {
respondError(c, 502, "verification_unavailable", fmt.Errorf("verifier success missing recovered_address"))
verified, ok := verifyPaidRequest(c)
if !ok {
c.Abort()
return
}

// Payment Verified. Store verification for downstream if needed (though we abort)
c.Set("payment_verification", verifyResp)
c.Set("payment_context", paymentCtx)
c.Set("payment_verification", verified)
c.Set("payment_context", &verified.PaymentContext)

// Generate Receipt and Respond
// We treat the cached result as the AI result
// Generate receipt for cache hit using current request and cached result.
// Note: request_hash matches current request, response is from cache,
// but both are cryptographically valid since cache key ensures identical text.
if err := generateAndSendReceipt(c, *paymentCtx, verifyResp.RecoveredAddress, requestBody, cached.Result); err != nil {
if err := sendPaidResult(c, verified, requestBody, cached.Result); err != nil {
log.Printf("Failed to send cached response receipt: %v", err)
// generateAndSendReceipt already sent an error response (500)
}
Expand Down
125 changes: 119 additions & 6 deletions gateway/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -61,6 +62,21 @@ func withVerifierResponse(t *testing.T, status int, body string) {
t.Setenv("VERIFIER_URL", server.URL)
}

func withSlowVerifier(t *testing.T) {
t.Helper()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
return
case <-time.After(3 * time.Second):
}
}))
t.Cleanup(server.Close)
t.Setenv("VERIFIER_URL", server.URL)
t.Setenv("VERIFIER_TIMEOUT_SECONDS", "1")
}

func withCachedSummary(t *testing.T, text string) {
t.Helper()

Expand Down Expand Up @@ -121,13 +137,52 @@ func TestHandleSummarizeSanitizesVerifierInvalidSignatureDetail(t *testing.T) {
require.Equal(t, "test-correlation-id", response["correlation_id"])
}

func TestHandleSummarizeValidatesSignedBodyBeforeVerifier(t *testing.T) {
tests := []struct {
name string
body string
wantStatus int
}{
{
name: "malformed JSON",
body: `{"text":`,
wantStatus: http.StatusBadRequest,
},
{
name: "empty text",
body: `{"text":""}`,
wantStatus: http.StatusBadRequest,
},
{
name: "oversized body",
body: strings.Repeat("x", 10*1024*1024+1),
wantStatus: http.StatusRequestEntityTooLarge,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var verifierCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
verifierCalls.Add(1)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"is_valid":true,"recovered_address":"0xabc","error":""}`))
}))
t.Cleanup(server.Close)
t.Setenv("VERIFIER_URL", server.URL)

router := newSummarizeTestRouter()
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, signedSummarizeRequest(tt.body))

require.Equal(t, tt.wantStatus, recorder.Code)
require.Zero(t, verifierCalls.Load())
})
}
}

func TestHandleSummarizeSanitizesVerifierTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(1500 * time.Millisecond)
}))
t.Cleanup(server.Close)
t.Setenv("VERIFIER_URL", server.URL)
t.Setenv("VERIFIER_TIMEOUT_SECONDS", "1")
withSlowVerifier(t)

router := newSummarizeTestRouter()
recorder := httptest.NewRecorder()
Expand Down Expand Up @@ -207,6 +262,64 @@ func TestCacheHitMapsVerifierNonceReplay(t *testing.T) {
require.Equal(t, "test-correlation-id", response["correlation_id"])
}

func TestCacheHitMapsVerifierTimeout(t *testing.T) {
text := "cached timeout text"
withCachedSummary(t, text)
withSlowVerifier(t)

router := newCachedSummarizeTestRouter()
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, signedSummarizeRequest(`{"text":"`+text+`"}`))

require.Equal(t, http.StatusGatewayTimeout, recorder.Code)

var response map[string]string
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
require.Equal(t, "verifier_timeout", response["error"])
require.Equal(t, "test-correlation-id", response["correlation_id"])
}

func TestCacheHitMapsVerifierUnavailable(t *testing.T) {
text := "cached unavailable text"
withCachedSummary(t, text)
withVerifierResponse(t, http.StatusInternalServerError, `SENSITIVE_VERIFIER_FAILURE`)

router := newCachedSummarizeTestRouter()
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, signedSummarizeRequest(`{"text":"`+text+`"}`))

require.Equal(t, http.StatusBadGateway, recorder.Code)
require.NotContains(t, recorder.Body.String(), "SENSITIVE_VERIFIER_FAILURE")

var response map[string]string
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
require.Equal(t, "verification_unavailable", response["error"])
require.Equal(t, "test-correlation-id", response["correlation_id"])
}

func TestCacheHitSuccessEmitsReceiptHeader(t *testing.T) {
text := "cached success text"
withCachedSummary(t, text)
withVerifierResponse(t, http.StatusOK, `{"is_valid":true,"recovered_address":"0xabc","error":""}`)
t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
resetServerPrivateKeyForTest(t)

origStore := getActiveReceiptStore()
setActiveReceiptStore(NewInMemoryReceiptStore())
t.Cleanup(func() { setActiveReceiptStore(origStore) })

router := newCachedSummarizeTestRouter()
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, signedSummarizeRequest(`{"text":"`+text+`"}`))

require.Equal(t, http.StatusOK, recorder.Code)
require.NotEmpty(t, recorder.Header().Get("X-402-Receipt"))

var response map[string]string
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
require.Equal(t, "cached summary", response["result"])
}

func TestHandleSummarizePreservesAIProviderTimeoutStatus(t *testing.T) {
origProvider := aiProvider
t.Cleanup(func() { aiProvider = origProvider })
Expand Down
60 changes: 11 additions & 49 deletions gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,33 +347,12 @@ func main() {
// applied by middleware and returns appropriate HTTP errors (402, 403, 504,
// 500) to the client.
func handleSummarize(c *gin.Context) {
// 1. Payment Verification
// Note: CacheMiddleware aborts on cache HIT, so this handler only runs on cache MISS or when caching is disabled
var requestBody []byte
var err error

signature := c.GetHeader("X-402-Signature")
nonce := c.GetHeader("X-402-Nonce")
timestampHeader := c.GetHeader("X-402-Timestamp")

// Basic check
if signature == "" || nonce == "" {
c.JSON(402, gin.H{
"error": "Payment Required",
"message": "Please sign the payment context",
"paymentContext": createPaymentContext(),
})
return
}

if timestampHeader == "" {
respondError(c, 400, "invalid_timestamp", fmt.Errorf("missing X-402-Timestamp header"))
return
}

timestampValue, err := strconv.ParseUint(timestampHeader, 10, 64)
if err != nil || timestampValue == 0 {
respondError(c, 400, "invalid_timestamp", fmt.Errorf("invalid X-402-Timestamp header"))
if c.GetHeader("X-402-Signature") == "" || c.GetHeader("X-402-Nonce") == "" {
verifyPaidRequest(c)
return
}

Expand All @@ -400,26 +379,6 @@ func handleSummarize(c *gin.Context) {
}
}

// Verify
verifyResp, paymentCtx, err := verifyPayment(c.Request.Context(), signature, nonce, uint64(timestampValue))
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
respondError(c, 504, "verifier_timeout", err)
} else {
respondError(c, 502, "verification_unavailable", err)
}
return
}

if !verifyResp.IsValid {
respondVerificationFailure(c, verifyResp)
return
}
if verifyResp.RecoveredAddress == "" {
respondError(c, 502, "verification_unavailable", fmt.Errorf("verifier success missing recovered_address"))
return
}

// 2. Parse Request
var req SummarizeRequest
if err := json.Unmarshal(requestBody, &req); err != nil {
Expand All @@ -433,7 +392,13 @@ func handleSummarize(c *gin.Context) {
return
}

// 3. Call AI Service
// 3. Payment Verification
verified, ok := verifyPaidRequest(c)
if !ok {
return
}

// 4. Call AI Service
summary, err := aiProvider.Generate(c.Request.Context(), req.Text)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(c.Request.Context().Err(), context.DeadlineExceeded) {
Expand All @@ -444,12 +409,9 @@ func handleSummarize(c *gin.Context) {
return
}

// 4. Generate & Send Receipt
if err := generateAndSendReceipt(c, *paymentCtx, verifyResp.RecoveredAddress, requestBody, summary); err != nil {
// 5. Generate & Send Receipt
if err := sendPaidResult(c, verified, requestBody, summary); err != nil {
log.Printf("Failed to generate receipt: %v", err)
// generateAndSendReceipt sends error response if it fails?
// No, it returns error, we might have already written status if we aren't careful.
// Let's implement generateAndSendReceipt to handle sending response.
return
}
}
Expand Down
Loading
Loading