diff --git a/account/balance.go b/account/balance.go
index ca8a101b..eaf20d32 100644
--- a/account/balance.go
+++ b/account/balance.go
@@ -279,8 +279,10 @@ func handleDepositPage(w http.ResponseWriter, r *http.Request) {
if StripeEnabled() {
sb.WriteString(renderStripeDeposit(sess.Account, ""))
}
- if x402.TopUpRequirement(100) != nil {
- sb.WriteString(`Pay with crypto ` + wallet.Page(sess.Account) + ` `)
+ if CryptoConfigured() {
+ sb.WriteString(cryptoPage(r))
+ } else if x402.TopUpRequirement(100) != nil {
+ sb.WriteString(`
Convert wallet USDC to credits
`)
} else if !StripeEnabled() {
sb.WriteString(`No payment methods available.
`)
}
@@ -513,8 +515,10 @@ func handleTopupJSON(w http.ResponseWriter, r *http.Request) {
}
methods := []TopupMethod{}
- if x402.TopUpRequirement(100) != nil {
- methods = append(methods, TopupMethod{Type: "usdc", Path: "/account/topup"})
+ if CryptoConfigured() {
+ methods = append(methods, TopupMethod{Type: "usdc", Path: "/account/topup#crypto"})
+ } else if x402.TopUpRequirement(100) != nil {
+ methods = append(methods, TopupMethod{Type: "usdc", Path: "/wallet"})
}
if StripeEnabled() {
diff --git a/account/crypto.go b/account/crypto.go
new file mode 100644
index 00000000..1ed0cf43
--- /dev/null
+++ b/account/crypto.go
@@ -0,0 +1,360 @@
+package account
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "html"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "mu/internal/app"
+ "mu/internal/auth"
+ "mu/internal/data"
+ "mu/internal/x402"
+)
+
+// Each authorization belongs to one account before anything is signed. Never
+// accept a customer-supplied transaction as proof that their account paid.
+type cryptoPayment struct {
+ Account string `json:"account"`
+ Created string `json:"account_created"`
+ Credits int `json:"credits"`
+ Nonce string `json:"nonce"`
+ Before int64 `json:"before"`
+ Block uint64 `json:"block"`
+ Requirement x402.PaymentRequirements `json:"requirement"`
+ From string `json:"from"`
+ Signature string `json:"signature,omitempty"`
+ Transaction string `json:"transaction,omitempty"`
+ Status string `json:"status"`
+}
+
+var cryptoStore = struct {
+ sync.Mutex
+ payments map[string]cryptoPayment
+ err error
+}{payments: map[string]cryptoPayment{}}
+var cryptoRunning sync.Map
+var cryptoLocks sync.Map
+var cryptoWake = make(chan string, 64)
+
+// CryptoConfigured advertises only native USDC on Base, the checkout supported
+// by the browser signer and receipt verifier.
+func CryptoConfigured() bool { return cryptoRequirement(100) != nil }
+
+func cryptoRequirement(credits int) *x402.PaymentRequirements {
+ r := x402.TopUpRequirement(credits)
+ if r == nil || x402.NormalizeNetwork(r.Network) != "eip155:8453" || !strings.EqualFold(r.Asset, baseUSDC) || r.Extra["name"] != "USD Coin" || r.Extra["version"] != "2" || !cryptoAddress(r.PayTo) {
+ return nil
+ }
+ r.MaxTimeoutSeconds = 600
+ return r
+}
+
+// StartCryptoPayments resumes pending settlements independently of open pages.
+func StartCryptoPayments() {
+ cryptoStore.Lock()
+ cryptoStore.err = data.LoadJSON("crypto_payments.json", &cryptoStore.payments)
+ if errors.Is(cryptoStore.err, os.ErrNotExist) {
+ cryptoStore.err = nil
+ }
+ if cryptoStore.payments == nil {
+ cryptoStore.payments = map[string]cryptoPayment{}
+ }
+ cryptoStore.Unlock()
+ go func() {
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+ for {
+ cryptoStore.Lock()
+ var ids []string
+ if cryptoStore.err == nil {
+ for id, p := range cryptoStore.payments {
+ if p.Status == "pending" {
+ ids = append(ids, id)
+ }
+ }
+ }
+ cryptoStore.Unlock()
+ for _, id := range ids {
+ settleCrypto(id)
+ }
+ select {
+ case id := <-cryptoWake:
+ settleCrypto(id)
+ case <-ticker.C:
+ }
+ }
+ }()
+}
+
+func cryptoLock(id string) func() {
+ lock, _ := cryptoLocks.LoadOrStore(id, &sync.Mutex{})
+ mu := lock.(*sync.Mutex)
+ mu.Lock()
+ return mu.Unlock
+}
+
+func saveCrypto(p cryptoPayment) error {
+ cryptoStore.Lock()
+ defer cryptoStore.Unlock()
+ old, exists := cryptoStore.payments[p.Account]
+ cryptoStore.payments[p.Account] = p
+ if err := data.SaveJSON("crypto_payments.json", cryptoStore.payments); err != nil {
+ if exists {
+ cryptoStore.payments[p.Account] = old
+ } else {
+ delete(cryptoStore.payments, p.Account)
+ }
+ return err
+ }
+ return nil
+}
+
+func cryptoAddress(s string) bool {
+ b, err := hex.DecodeString(strings.TrimPrefix(s, "0x"))
+ return strings.HasPrefix(s, "0x") && err == nil && len(b) == 20 && strings.Trim(s[2:], "0") != ""
+}
+
+func cryptoPage(r *http.Request) string {
+ return `Pay with crypto Pay directly from your wallet with USDC on Base. 1 USDC buys 100 credits.
Use a browser wallet or open this page in your wallet’s browser. Confirm the payment in your wallet; no deposit into Micro’s wallet is needed.
JavaScript and a compatible wallet are needed for crypto checkout. `
+}
+
+// CryptoHandler creates a request, accepts its signature, or reads its status.
+// Signed terms come from our durable record, never from the browser's payload.
+func CryptoHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "private, no-store")
+ w.Header().Set("Content-Type", "application/json")
+ sess, acc, err := auth.RequireSession(r)
+ fail := func(code int, message string) {
+ w.WriteHeader(code)
+ app.RespondJSON(w, map[string]string{"error": message})
+ }
+ if err != nil || sess == nil || acc == nil || sess.Type != "account" {
+ fail(401, "Sign in to pay with crypto.")
+ return
+ }
+ if r.Method != "GET" && r.Method != "POST" {
+ app.MethodNotAllowed(w, r)
+ return
+ }
+ if r.Method == "POST" && !auth.StrictCSRF(r) {
+ fail(403, "Reload the page and try again.")
+ return
+ }
+ var in struct {
+ Amount string `json:"amount"`
+ From string `json:"from"`
+ Nonce string `json:"nonce"`
+ Signature string `json:"signature"`
+ }
+ if r.Method == "POST" {
+ if err := auth.CheckPostRate(acc.ID); err != nil {
+ fail(429, err.Error())
+ return
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&in); err != nil {
+ fail(400, "Invalid payment request.")
+ return
+ }
+ }
+ unlock := cryptoLock(acc.ID)
+ defer unlock()
+ cryptoStore.Lock()
+ loadErr := cryptoStore.err
+ p, exists := cryptoStore.payments[acc.ID]
+ cryptoStore.Unlock()
+ if loadErr != nil {
+ fail(503, "Crypto payments are temporarily unavailable.")
+ return
+ }
+ created := strconv.FormatInt(acc.Created.UnixNano(), 10)
+ if exists && p.Created != created {
+ fail(409, "A previous payment needs review. Contact support.")
+ return
+ }
+ if r.Method == "POST" && in.Signature != "" {
+ if !exists || p.Nonce != in.Nonce {
+ fail(409, "This payment request has changed. Reload the page.")
+ return
+ }
+ if p.Status == "new" {
+ sig, e := hex.DecodeString(strings.TrimPrefix(in.Signature, "0x"))
+ if e != nil || len(sig) != 65 || !strings.HasPrefix(in.Signature, "0x") {
+ fail(400, "Invalid wallet signature.")
+ return
+ }
+ if time.Now().Unix() >= p.Before {
+ fail(409, "This request expired. Start a new payment.")
+ return
+ }
+ p.Signature = in.Signature
+ if err := x402.VerifySigned(cryptoPayload(p), &p.Requirement); err != nil {
+ app.Log("account", "crypto verification failed for %s: %v", acc.ID, err)
+ fail(400, "The payment could not be verified. Check your USDC balance on Base and try again.")
+ return
+ }
+ p.Status = "pending"
+ if err := saveCrypto(p); err != nil {
+ fail(503, "Could not save the payment. Nothing has been submitted.")
+ return
+ }
+ }
+ if p.Status == "pending" {
+ select {
+ case cryptoWake <- acc.ID:
+ default:
+ }
+ }
+ } else if r.Method == "POST" {
+ if !CryptoConfigured() {
+ fail(503, "Crypto checkout is not available on this instance.")
+ return
+ }
+ if exists && p.Status == "pending" {
+ fail(409, "Your previous payment is still being checked. Do not pay again.")
+ return
+ }
+ dollars, e := strconv.Atoi(in.Amount)
+ if e != nil || dollars < 1 || dollars > maxTopupDollars || !cryptoAddress(in.From) {
+ fail(400, "Enter 1–500 USDC and connect a valid wallet.")
+ return
+ }
+ requirement := cryptoRequirement(dollars * 100)
+ if requirement == nil {
+ fail(503, "Crypto checkout is not available on this instance.")
+ return
+ }
+ // Read the chain before authorizing a payment so recovery has a bounded range.
+ block, e := cryptoBlock()
+ if e != nil {
+ fail(503, "Could not reach the payment network. Try again shortly.")
+ return
+ }
+ nonce := make([]byte, 32)
+ if _, e = rand.Read(nonce); e != nil {
+ fail(503, "Could not create payment.")
+ return
+ }
+ p = cryptoPayment{Account: acc.ID, Created: created, Credits: dollars * 100, Nonce: "0x" + hex.EncodeToString(nonce), Before: time.Now().Unix() + 600, Block: block, Requirement: *requirement, From: strings.ToLower(in.From), Status: "new"}
+ if e = saveCrypto(p); e != nil {
+ fail(503, "Could not save payment request.")
+ return
+ }
+ }
+ if p.Nonce == "" {
+ app.RespondJSON(w, map[string]string{"status": "none"})
+ return
+ }
+ app.RespondJSON(w, map[string]any{"status": p.Status, "nonce": p.Nonce, "credits": p.Credits, "transaction": p.Transaction, "typedData": cryptoTypedData(p)})
+}
+
+func cryptoAuthorization(p cryptoPayment) map[string]string {
+ return map[string]string{"from": p.From, "to": p.Requirement.PayTo, "value": p.Requirement.AmountAtomic(), "validAfter": "0", "validBefore": strconv.FormatInt(p.Before, 10), "nonce": p.Nonce}
+}
+
+func cryptoTypedData(p cryptoPayment) map[string]any {
+ type field struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ }
+ return map[string]any{
+ "domain": map[string]any{"name": "USD Coin", "version": "2", "chainId": 8453, "verifyingContract": p.Requirement.Asset},
+ "primaryType": "TransferWithAuthorization",
+ "types": map[string]any{
+ "EIP712Domain": []field{{"name", "string"}, {"version", "string"}, {"chainId", "uint256"}, {"verifyingContract", "address"}},
+ "TransferWithAuthorization": []field{{"from", "address"}, {"to", "address"}, {"value", "uint256"}, {"validAfter", "uint256"}, {"validBefore", "uint256"}, {"nonce", "bytes32"}},
+ }, "message": cryptoAuthorization(p),
+ }
+}
+
+func settleCrypto(id string) {
+ if _, running := cryptoRunning.LoadOrStore(id, true); running {
+ return
+ }
+ defer cryptoRunning.Delete(id)
+ unlock := cryptoLock(id)
+ defer unlock()
+ cryptoStore.Lock()
+ p := cryptoStore.payments[id]
+ cryptoStore.Unlock()
+ if p.Status != "pending" {
+ return
+ }
+ acc, err := auth.GetAccount(id)
+ if err != nil || strconv.FormatInt(acc.Created.UnixNano(), 10) != p.Created {
+ return
+ }
+ tx := p.Transaction
+ if tx == "" {
+ // Recover a broadcast whose facilitator response was lost before retrying.
+ tx, err = cryptoReceipt(p)
+ if errors.Is(err, errCryptoExpired) {
+ p.Status = "expired"
+ p.Signature = ""
+ saveErr := saveCrypto(p)
+ if saveErr != nil {
+ app.Log("account", "crypto expiry save failed: %v", saveErr)
+ }
+ return
+ }
+ if err != nil {
+ app.Log("account", "crypto receipt check failed: %v", err)
+ return
+ }
+ if tx == "" && time.Now().Unix() < p.Before {
+ res, e := x402.SettleSigned(cryptoPayload(p), &p.Requirement)
+ if e != nil {
+ app.Log("account", "crypto settlement pending for %s: %v", id, e)
+ return
+ }
+ if !res.Success || !cryptoHash(res.Transaction) || x402.NormalizeNetwork(res.Network) != "eip155:8453" {
+ return
+ }
+ tx = strings.ToLower(res.Transaction)
+ }
+ }
+ if tx == "" {
+ return
+ } // Keep uncertain outcomes recoverable; never ask for another payment.
+ p.Transaction = tx
+ if err := saveCrypto(p); err != nil {
+ return
+ }
+ _, err = CreditOnce(id, p.Credits, "topup_usdc", "crypto:"+p.Nonce, map[string]interface{}{"source": "usdc", "tx": tx, "network": p.Requirement.Network, "from": p.From})
+ if err != nil {
+ app.Log("account", "crypto credit pending for %s: %v", id, err)
+ return
+ }
+ p.Status = "paid"
+ p.Signature = ""
+ if err := saveCrypto(p); err != nil {
+ app.Log("account", "crypto receipt save failed: %v", err)
+ }
+}
+
+func cryptoHash(s string) bool {
+ b, e := hex.DecodeString(strings.TrimPrefix(s, "0x"))
+ return strings.HasPrefix(s, "0x") && e == nil && len(b) == 32
+}
+
+var errCryptoExpired = errors.New("payment authorization expired")
+
+func cryptoPayload(p cryptoPayment) string {
+ payload := map[string]any{"x402Version": 2, "accepted": p.Requirement, "payload": map[string]any{"signature": p.Signature, "authorization": cryptoAuthorization(p)}}
+ if p.Requirement.Amount == "" {
+ payload["x402Version"] = 1
+ delete(payload, "accepted")
+ payload["scheme"] = "exact"
+ payload["network"] = p.Requirement.Network
+ }
+ raw, _ := json.Marshal(payload)
+ return base64.StdEncoding.EncodeToString(raw)
+}
diff --git a/account/crypto_receipt.go b/account/crypto_receipt.go
new file mode 100644
index 00000000..c843319c
--- /dev/null
+++ b/account/crypto_receipt.go
@@ -0,0 +1,166 @@
+package account
+
+import (
+ "bytes"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/sha3"
+ "mu/service/wallet"
+)
+
+const baseUSDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
+
+var cryptoHTTP = &http.Client{Timeout: 15 * time.Second}
+
+func cryptoRPC(method string, params any, out any) error {
+ b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params})
+ resp, err := cryptoHTTP.Post(wallet.BaseRPCURL(), "application/json", bytes.NewReader(b))
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ return fmt.Errorf("payment node returned %d", resp.StatusCode)
+ }
+ var result struct {
+ Result json.RawMessage `json:"result"`
+ Error json.RawMessage `json:"error"`
+ }
+ if err = json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&result); err != nil {
+ return err
+ }
+ if len(result.Error) > 0 && string(result.Error) != "null" {
+ return fmt.Errorf("payment node rejected %s", method)
+ }
+ if len(result.Result) == 0 || string(result.Result) == "null" {
+ return fmt.Errorf("payment node has no %s result", method)
+ }
+ return json.Unmarshal(result.Result, out)
+}
+
+func cryptoBlock() (uint64, error) {
+ var chain, block string
+ if err := cryptoRPC("eth_chainId", []any{}, &chain); err != nil {
+ return 0, err
+ }
+ if chain != "0x2105" {
+ return 0, fmt.Errorf("payment node is not Base")
+ }
+ if err := cryptoRPC("eth_blockNumber", []any{}, &block); err != nil {
+ return 0, err
+ }
+ return strconv.ParseUint(strings.TrimPrefix(block, "0x"), 16, 64)
+}
+
+func cryptoTopic(s string) string {
+ h := sha3.NewLegacyKeccak256()
+ h.Write([]byte(s))
+ return "0x" + hex.EncodeToString(h.Sum(nil))
+}
+func cryptoAddressTopic(s string) string {
+ return "0x" + strings.Repeat("0", 24) + strings.ToLower(strings.TrimPrefix(s, "0x"))
+}
+
+type cryptoLog struct {
+ Address string `json:"address"`
+ Topics []string `json:"topics"`
+ Data string `json:"data"`
+ Transaction string `json:"transactionHash"`
+ Removed bool `json:"removed"`
+}
+
+// A nonce-use event alone is insufficient: require the successful transaction's
+// matching USDC Transfer too. Only finalized blocks recover a missing receipt.
+func cryptoReceipt(p cryptoPayment) (string, error) {
+ if _, err := cryptoBlock(); err != nil {
+ return "", err
+ }
+ var block struct {
+ Number string `json:"number"`
+ Timestamp string `json:"timestamp"`
+ }
+ if err := cryptoRPC("eth_getBlockByNumber", []any{"finalized", false}, &block); err != nil {
+ return "", err
+ }
+ end, err := strconv.ParseUint(strings.TrimPrefix(block.Number, "0x"), 16, 64)
+ if err != nil {
+ return "", err
+ }
+ if end < p.Block {
+ return "", nil
+ }
+ stamp, err := strconv.ParseInt(strings.TrimPrefix(block.Timestamp, "0x"), 16, 64)
+ if err != nil {
+ return "", err
+ }
+ if stamp > p.Before {
+ // A restart may be weeks later. Bound the log query by the signed
+ // expiry rather than asking a node for weeks of chain history.
+ lo, hi := p.Block, end
+ for lo < hi {
+ mid := lo + (hi-lo)/2
+ var at struct {
+ Timestamp string `json:"timestamp"`
+ }
+ if err := cryptoRPC("eth_getBlockByNumber", []any{fmt.Sprintf("0x%x", mid), false}, &at); err != nil {
+ return "", err
+ }
+ timestamp, err := strconv.ParseInt(strings.TrimPrefix(at.Timestamp, "0x"), 16, 64)
+ if err != nil {
+ return "", err
+ }
+ if timestamp < p.Before {
+ lo = mid + 1
+ } else {
+ hi = mid
+ }
+ }
+ block.Number = fmt.Sprintf("0x%x", lo)
+ }
+ var logs []cryptoLog
+ if err := cryptoRPC("eth_getLogs", []any{map[string]any{"address": p.Requirement.Asset, "fromBlock": fmt.Sprintf("0x%x", p.Block), "toBlock": block.Number, "topics": []any{cryptoTopic("AuthorizationUsed(address,bytes32)"), cryptoAddressTopic(p.From), p.Nonce}}}, &logs); err != nil {
+ return "", err
+ }
+ for _, l := range logs {
+ if l.Removed || !strings.EqualFold(l.Address, p.Requirement.Asset) || len(l.Topics) != 3 || !strings.EqualFold(l.Topics[0], cryptoTopic("AuthorizationUsed(address,bytes32)")) || !strings.EqualFold(l.Topics[1], cryptoAddressTopic(p.From)) || !strings.EqualFold(l.Topics[2], p.Nonce) || !cryptoHash(l.Transaction) {
+ continue
+ }
+ var receipt struct {
+ Status string `json:"status"`
+ Transaction string `json:"transactionHash"`
+ Logs []cryptoLog `json:"logs"`
+ }
+ if err := cryptoRPC("eth_getTransactionReceipt", []any{l.Transaction}, &receipt); err != nil {
+ return "", err
+ }
+ if receipt.Status != "0x1" || !strings.EqualFold(receipt.Transaction, l.Transaction) {
+ continue
+ }
+ for _, transfer := range receipt.Logs {
+ if validCryptoTransfer(p, transfer) {
+ return strings.ToLower(l.Transaction), nil
+ }
+ }
+ }
+ // Once the finalized chain is beyond the authorization window, an absent
+ // payment can no longer arrive. It is safe to offer another checkout.
+ if stamp > p.Before {
+ return "", errCryptoExpired
+ }
+ return "", nil
+}
+
+func validCryptoTransfer(p cryptoPayment, l cryptoLog) bool {
+ if l.Removed || !strings.EqualFold(l.Address, p.Requirement.Asset) || len(l.Topics) != 3 || !strings.EqualFold(l.Topics[0], cryptoTopic("Transfer(address,address,uint256)")) || !strings.EqualFold(l.Topics[1], cryptoAddressTopic(p.From)) || !strings.EqualFold(l.Topics[2], cryptoAddressTopic(p.Requirement.PayTo)) {
+ return false
+ }
+ raw, err := hex.DecodeString(strings.TrimPrefix(l.Data, "0x"))
+ return err == nil && len(raw) == 32 && strings.EqualFold(l.Data, "0x"+fmt.Sprintf("%064x", creditsAsUSDCAtomic(p.Credits)))
+}
diff --git a/account/crypto_test.go b/account/crypto_test.go
new file mode 100644
index 00000000..ce326b8e
--- /dev/null
+++ b/account/crypto_test.go
@@ -0,0 +1,276 @@
+package account
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "mu/internal/auth"
+ "mu/internal/data"
+ "mu/internal/x402"
+)
+
+func cryptoFixture(t *testing.T) (*auth.Account, func(string, string, bool) *httptest.ResponseRecorder) {
+ t.Helper()
+ t.Setenv("X402_PAY_TO", "0x1111111111111111111111111111111111111111")
+ t.Setenv("X402_NETWORK", "eip155:8453")
+ t.Setenv("X402_VERSION", "2")
+ t.Setenv("X402_ASSETS", "USDC")
+ old, oldErr := cryptoStore.payments, cryptoStore.err
+ cryptoStore.payments, cryptoStore.err = map[string]cryptoPayment{}, nil
+ t.Cleanup(func() { cryptoStore.payments, cryptoStore.err = old, oldErr })
+ acc := &auth.Account{ID: "crypto_" + t.Name(), Approved: true, Created: time.Now()}
+ auth.SetAccountForTest(acc)
+ sess, err := auth.CreateSession(acc.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ auth.Logout(sess.Token)
+ auth.RemoveAccountForTest(acc.ID)
+ withLedger(func(l *ledger) bool { delete(transactions, acc.ID); delete(balances, acc.ID); return true })
+ })
+ call := func(method, body string, csrf bool) *httptest.ResponseRecorder {
+ r := httptest.NewRequest(method, "https://micro.example/account/crypto", strings.NewReader(body))
+ r.AddCookie(&http.Cookie{Name: "session", Value: sess.Token})
+ if csrf {
+ r.Header.Set("X-CSRF-Token", auth.CSRFToken(r))
+ }
+ w := httptest.NewRecorder()
+ CryptoHandler(w, r)
+ return w
+ }
+ return acc, call
+}
+
+func TestCryptoPaymentRecoveryAndIsolation(t *testing.T) {
+ acc, call := cryptoFixture(t)
+ oldHTTP, oldTransport := cryptoHTTP, http.DefaultTransport
+ defer func() { cryptoHTTP = oldHTTP; http.DefaultTransport = oldTransport }()
+ var broadcast atomic.Int32
+ var onchain atomic.Bool
+ tx := "0x" + strings.Repeat("a", 64)
+ var payment cryptoPayment
+ cryptoHTTP = &http.Client{Transport: stripeTransport(func(r *http.Request) (*http.Response, error) {
+ var req struct {
+ Method string `json:"method"`
+ }
+ json.NewDecoder(r.Body).Decode(&req)
+ var result any
+ switch req.Method {
+ case "eth_chainId":
+ result = "0x2105"
+ case "eth_blockNumber":
+ result = "0x100"
+ case "eth_getBlockByNumber":
+ result = map[string]string{"number": "0x200", "timestamp": fmt.Sprintf("0x%x", time.Now().Unix())}
+ case "eth_getLogs":
+ logs := []cryptoLog{}
+ if onchain.Load() {
+ logs = append(logs, cryptoLog{Address: baseUSDC, Topics: []string{cryptoTopic("AuthorizationUsed(address,bytes32)"), cryptoAddressTopic(payment.From), payment.Nonce}, Transaction: tx})
+ }
+ result = logs
+ case "eth_getTransactionReceipt":
+ result = map[string]any{"status": "0x1", "transactionHash": tx, "logs": []cryptoLog{cryptoTransferFixture(payment)}}
+ default:
+ t.Errorf("unexpected RPC %s", req.Method)
+ }
+ return stripeResponse(map[string]any{"result": result}), nil
+ })}
+ http.DefaultTransport = stripeTransport(func(r *http.Request) (*http.Response, error) {
+ var body struct {
+ Requirement struct {
+ Amount string `json:"amount"`
+ } `json:"paymentRequirements"`
+ }
+ json.NewDecoder(r.Body).Decode(&body)
+ if body.Requirement.Amount != "10000000" {
+ t.Errorf("browser changed amount: %q", body.Requirement.Amount)
+ }
+ switch r.URL.Path {
+ case "/platform/v2/x402/verify":
+ return stripeResponse(map[string]any{"isValid": true}), nil
+ case "/platform/v2/x402/settle":
+ broadcast.Add(1)
+ return stripeResponse(map[string]any{"success": true, "transaction": tx, "network": "eip155:8453"}), nil
+ default:
+ t.Errorf("unexpected facilitator path %s", r.URL.Path)
+ return nil, fmt.Errorf("unexpected endpoint")
+ }
+ })
+ if w := call("POST", `{"amount":"10","from":"0x2222222222222222222222222222222222222222"}`, false); w.Code != 403 {
+ t.Fatal("missing CSRF accepted", w.Code)
+ }
+ for _, amount := range []string{"0", "501", "1.5", "9999999999999999999999999", "10junk"} {
+ w := call("POST", fmt.Sprintf(`{"amount":%q,"from":"0x2222222222222222222222222222222222222222"}`, amount), true)
+ if w.Code != 400 {
+ t.Fatalf("invalid amount %s: %d", amount, w.Code)
+ }
+ }
+ w := call("POST", `{"amount":"10","from":"0x2222222222222222222222222222222222222222"}`, true)
+ if w.Code != 200 {
+ t.Fatal(w.Code, w.Body.String())
+ }
+ payment = cryptoStore.payments[acc.ID]
+ if payment.Credits != 1000 || !cryptoHash(payment.Nonce) {
+ t.Fatal("bad payment", payment)
+ }
+ if w := call("POST", fmt.Sprintf(`{"nonce":%q,"signature":%q}`, "0x"+strings.Repeat("b", 64), "0x"+strings.Repeat("1", 130)), true); w.Code != 409 {
+ t.Fatal("foreign nonce accepted")
+ }
+ other := &auth.Account{ID: acc.ID + "_other", Approved: true, Created: time.Now()}
+ auth.SetAccountForTest(other)
+ defer auth.RemoveAccountForTest(other.ID)
+ otherSession, err := auth.CreateSession(other.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer auth.Logout(otherSession.Token)
+ foreign := httptest.NewRequest("POST", "https://micro.example/account/crypto", strings.NewReader(fmt.Sprintf(`{"nonce":%q,"signature":%q}`, payment.Nonce, "0x"+strings.Repeat("1", 130))))
+ foreign.AddCookie(&http.Cookie{Name: "session", Value: otherSession.Token})
+ foreign.Header.Set("X-CSRF-Token", auth.CSRFToken(foreign))
+ foreignResult := httptest.NewRecorder()
+ CryptoHandler(foreignResult, foreign)
+ if foreignResult.Code != 409 {
+ t.Fatal("another account claimed this payment")
+ }
+ // Hold the worker while testing the handler and persisted signed terms.
+ cryptoRunning.Store(acc.ID, true)
+ w = call("POST", fmt.Sprintf(`{"nonce":%q,"signature":%q,"amount":"500","from":"0x3333333333333333333333333333333333333333"}`, payment.Nonce, "0x"+strings.Repeat("1", 130)), true)
+ if w.Code != 200 {
+ t.Fatal(w.Code, w.Body.String())
+ }
+ if w := call("POST", `{"amount":"10","from":"0x2222222222222222222222222222222222222222"}`, true); w.Code != 409 {
+ t.Fatal("replaced pending payment")
+ }
+ if strings.Contains(call("GET", "", false).Body.String(), "signature") {
+ t.Fatal("status disclosed signature")
+ }
+ var saved map[string]cryptoPayment
+ if err := data.LoadJSON("crypto_payments.json", &saved); err != nil {
+ t.Fatal(err)
+ }
+ cryptoStore.payments = saved
+ cryptoRunning.Delete(acc.ID)
+ var group sync.WaitGroup
+ for range 5 {
+ group.Add(1)
+ go func() { defer group.Done(); settleCrypto(acc.ID) }()
+ }
+ group.Wait()
+ if cryptoStore.payments[acc.ID].Status != "paid" || broadcast.Load() != 1 {
+ t.Fatalf("not settled once: %s / %d", cryptoStore.payments[acc.ID].Status, broadcast.Load())
+ }
+ // Simulate losing the facilitator response and retrying after restart. The
+ // nonce/Transfer receipt recovers the same payment without another broadcast.
+ payment.Status = "pending"
+ payment.Signature = "0x" + strings.Repeat("1", 130)
+ cryptoStore.payments[acc.ID] = payment
+ onchain.Store(true)
+ settleCrypto(acc.ID)
+ if broadcast.Load() != 1 || cryptoStore.payments[acc.ID].Status != "paid" {
+ t.Fatal("recovery rebroadcast or failed")
+ }
+ count, total := 0, 0
+ for _, tr := range transactions[acc.ID] {
+ if tr.Operation == "topup_usdc" {
+ count++
+ total += tr.Amount
+ }
+ }
+ if count != 1 || total != 1000 {
+ t.Fatalf("credited %d times, %d credits", count, total)
+ }
+}
+
+func cryptoTransferFixture(p cryptoPayment) cryptoLog {
+ return cryptoLog{Address: baseUSDC, Topics: []string{cryptoTopic("Transfer(address,address,uint256)"), cryptoAddressTopic(p.From), cryptoAddressTopic(p.Requirement.PayTo)}, Data: fmt.Sprintf("0x%064x", creditsAsUSDCAtomic(p.Credits))}
+}
+
+func TestCryptoReceiptRejectsWrongTransfer(t *testing.T) {
+ acc, _ := cryptoFixture(t)
+ p := cryptoPayment{Account: acc.ID, Created: strconv.FormatInt(acc.Created.UnixNano(), 10), Credits: 1000, From: "0x2222222222222222222222222222222222222222"}
+ p.Requirement = *x402.TopUpRequirement(1000)
+ for _, change := range []func(*cryptoLog){
+ func(l *cryptoLog) { l.Address = "0x3333333333333333333333333333333333333333" },
+ func(l *cryptoLog) { l.Topics[1] = cryptoAddressTopic(p.Requirement.PayTo) },
+ func(l *cryptoLog) { l.Topics[2] = cryptoAddressTopic(p.From) },
+ func(l *cryptoLog) { l.Data = fmt.Sprintf("0x%064x", 1) },
+ func(l *cryptoLog) { l.Removed = true },
+ func(l *cryptoLog) { l.Topics = l.Topics[:2] },
+ } {
+ l := cryptoTransferFixture(p)
+ if !validCryptoTransfer(p, l) {
+ t.Fatal("valid transfer rejected")
+ }
+ change(&l)
+ if validCryptoTransfer(p, l) {
+ t.Fatal("incorrect transfer accepted")
+ }
+ }
+}
+
+func TestCryptoExpiryRequiresFinalizedChain(t *testing.T) {
+ acc, _ := cryptoFixture(t)
+ oldHTTP := cryptoHTTP
+ defer func() { cryptoHTTP = oldHTTP }()
+ p := cryptoPayment{Account: acc.ID, Created: strconv.FormatInt(acc.Created.UnixNano(), 10), Credits: 1000, From: "0x2222222222222222222222222222222222222222", Nonce: "0x" + strings.Repeat("a", 64), Block: 100, Before: 1600, Status: "pending", Requirement: *x402.TopUpRequirement(1000)}
+ cryptoStore.payments[acc.ID] = p
+ finalized := uint64(200)
+ chain := "0x2105"
+ cryptoHTTP = &http.Client{Transport: stripeTransport(func(r *http.Request) (*http.Response, error) {
+ var req struct {
+ Method string `json:"method"`
+ Params []json.RawMessage `json:"params"`
+ }
+ json.NewDecoder(r.Body).Decode(&req)
+ var result any
+ switch req.Method {
+ case "eth_chainId":
+ result = chain
+ case "eth_blockNumber":
+ result = "0x10000"
+ case "eth_getBlockByNumber":
+ var label string
+ json.Unmarshal(req.Params[0], &label)
+ n := finalized
+ if label != "finalized" {
+ n, _ = strconv.ParseUint(strings.TrimPrefix(label, "0x"), 16, 64)
+ }
+ result = map[string]string{"number": fmt.Sprintf("0x%x", n), "timestamp": fmt.Sprintf("0x%x", 1000+n*2)}
+ case "eth_getLogs":
+ var filter map[string]string
+ json.Unmarshal(req.Params[0], &filter)
+ if finalized > 300 && filter["toBlock"] != "0x12c" {
+ t.Errorf("unbounded recovery: %s", filter["toBlock"])
+ }
+ result = []cryptoLog{}
+ default:
+ t.Fatalf("unexpected %s", req.Method)
+ }
+ return stripeResponse(map[string]any{"result": result}), nil
+ })}
+ // Wall-clock expiry cannot discard a transfer that is not finalized yet.
+ settleCrypto(acc.ID)
+ if cryptoStore.payments[acc.ID].Status != "pending" {
+ t.Fatal("expired before finality")
+ }
+ chain = "0x1"
+ finalized = 10000
+ settleCrypto(acc.ID)
+ if cryptoStore.payments[acc.ID].Status != "pending" {
+ t.Fatal("trusted another chain")
+ }
+ chain = "0x2105"
+ settleCrypto(acc.ID)
+ if cryptoStore.payments[acc.ID].Status != "expired" {
+ t.Fatal("did not release confirmed unpaid request")
+ }
+}
diff --git a/internal/app/html/mu.js b/internal/app/html/mu.js
index 3cb60b30..182433c0 100644
--- a/internal/app/html/mu.js
+++ b/internal/app/html/mu.js
@@ -1201,6 +1201,51 @@ if (typeof document !== 'undefined') {
};
render();
});
+ // Buy credits with an authorization signed in the customer's own wallet.
+ const cryptoForm=document.getElementById('crypto-checkout');
+ if(cryptoForm){
+ const amount=cryptoForm.querySelector('[name="amount"]'),button=cryptoForm.querySelector('button[type="submit"]'),status=document.getElementById('crypto-status'),total=document.getElementById('crypto-total');
+ let pending=false,timer;
+ const request=async body=>{
+ const response=await fetch('/account/crypto',{method:body?'POST':'GET',credentials:'same-origin',headers:{'Accept':'application/json','Content-Type':'application/json','X-CSRF-Token':cryptoForm.dataset.csrf},...(body?{body:JSON.stringify(body)}:{})});
+ const result=await response.json();
+ if(!response.ok){const error=new Error(result.error||'Could not check payment.');error.paymentRejected=response.status>=400&&response.status<500;throw error;}
+ return result;
+ };
+ const show=payment=>{
+ pending=payment.status==='pending';button.disabled=pending;amount.disabled=pending;
+ if(pending){status.textContent='Payment is being confirmed. You can close this page; your credits will be added automatically. Do not pay again.';clearTimeout(timer);timer=setTimeout(check,5000);}
+ else if(payment.status==='paid'){status.textContent=payment.credits.toLocaleString()+' credits added to your account.';}
+ else if(payment.status==='expired'){status.textContent='The payment expired without a confirmed transfer. You can try again.';}
+ else if(status.textContent.startsWith('Checking whether')){status.textContent='No payment was submitted. You can try again.';}
+ };
+ const check=async()=>{try{show(await request());}catch(error){status.textContent=error.message+' Reload this page to check your payment before paying again.';button.disabled=true;timer=setTimeout(check,10000);}};
+ amount.addEventListener('input',()=>{const n=Number(amount.value);total.textContent=Number.isInteger(n)&&n>=1&&n<=500?n+' USDC = '+(n*100).toLocaleString()+' credits':'Enter 1–500 USDC.';});
+ cryptoForm.addEventListener('submit',async event=>{
+ event.preventDefault();if(pending)return;button.disabled=true;amount.disabled=true;
+ let signed=false;
+ try{
+ const previous=await request();if(previous.status==='pending'){show(previous);return;}
+ const provider=window.ethereum;
+ if(!provider?.request)throw new Error('Open this page in your wallet’s browser, or enable a compatible browser wallet.');
+ status.textContent='Connect your wallet…';
+ const accounts=await provider.request({method:'eth_requestAccounts'});
+ if(!accounts?.[0])throw new Error('No wallet account selected.');
+ if(await provider.request({method:'eth_chainId'})!=='0x2105')await provider.request({method:'wallet_switchEthereumChain',params:[{chainId:'0x2105'}]});
+ if(await provider.request({method:'eth_chainId'})!=='0x2105')throw new Error('Switch your wallet to Base to continue.');
+ const payment=await request({amount:amount.value,from:accounts[0]});
+ status.textContent='Confirm '+(payment.credits/100)+' USDC for '+payment.credits.toLocaleString()+' credits in your wallet.';
+ const signature=await provider.request({method:'eth_signTypedData_v4',params:[accounts[0],JSON.stringify(payment.typedData)]});
+ signed=true;pending=true;amount.disabled=true;
+ show(await request({nonce:payment.nonce,signature}));
+ }catch(error){
+ if(signed&&!error.paymentRejected){status.textContent='Checking whether your payment was submitted. Do not pay again.';timer=setTimeout(check,2000);}
+ else{pending=false;status.textContent=error.code===4001?'Payment canceled in your wallet.':error.message||'Could not start payment.';button.disabled=false;amount.disabled=false;}
+ }
+ });
+ button.disabled=true;check();
+ window.addEventListener('pagehide',()=>clearTimeout(timer));
+ }
// A live PTY, loaded only on Shell. Commands and output stay in memory.
const terminalHost=document.getElementById('shell-terminal');
if(terminalHost){
diff --git a/internal/server/pricing.go b/internal/server/pricing.go
index ca53aa46..5915ff90 100644
--- a/internal/server/pricing.go
+++ b/internal/server/pricing.go
@@ -49,6 +49,9 @@ func PricingHandler(w http.ResponseWriter, r *http.Request) {
b.WriteString(`Get started
`)
}
}
+ if account.CryptoConfigured() {
+ b.WriteString(`Pay with crypto
Buy credits directly from your wallet with USDC on Base. 1 USDC = 100 credits.
`)
+ }
b.WriteString(`What uses credits? Credits pay for assistant replies and chargeable service operations, whether you use a service directly or through Micro. A reply without paid tools costs ` + strconv.Itoa(messageCost) + ` credits. Paid operations, such as web searches, directions, sending messages and generating apps, are added to that cost.
A task can use several operations, so its total depends on what Micro needs to do. Included services remain available when you run out of credits; chargeable operations need an available allowance or balance.
Usage details and limits You can also use services directly. Keeping notes and documents, managing your calendar, storing files and browsing published news, videos and market prices do not use credits.
Files includes ` + strconv.Itoa(files.MaxOwnerBytes/(1<<20)) + ` MiB per account, up to ` + strconv.Itoa(files.MaxBytes/(1<<20)) + ` MiB per file. The same file limits apply on every plan.
Explore services
Signup credits are granted once. Daily credits reset at 00:00 UTC and do not roll over.
` + account.PricingTableHTML() + ` `)
b.WriteString(`Tools for agents Use Micro’s services from your own agent through MCP or the API, with the same service rates and account balance.
Developer access
`)
app.Respond(w, r, app.Response{Title: "Pricing", Description: description, HTML: b.String()})
diff --git a/internal/server/routes.go b/internal/server/routes.go
index 52cc0cec..b9364265 100644
--- a/internal/server/routes.go
+++ b/internal/server/routes.go
@@ -549,6 +549,7 @@ func registerRoutes() {
// obvious rather than nested three switches deep.
http.HandleFunc("/wallet/convert", account.ConvertUSDC)
http.HandleFunc("/account/convert", account.ConvertUSDC)
+ http.HandleFunc("/account/crypto", account.CryptoHandler)
// The money actions. account/ owns them because it owns the ledger.
http.HandleFunc("/wallet/", account.BalanceHandler)
http.HandleFunc(imageproxy.Path, imageproxy.Handler)
diff --git a/internal/server/server.go b/internal/server/server.go
index 4244e305..e5a099cb 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -6,6 +6,7 @@ import (
gmlogger "go-micro.dev/v6/logger"
+ "mu/account"
"mu/internal/app"
"mu/internal/persist"
"mu/internal/service"
@@ -90,6 +91,7 @@ func Run(addr string) {
app.Log("main", "boot: catalogue in %s", time.Since(phase).Round(time.Millisecond))
phase = time.Now()
+ account.StartCryptoPayments()
registerRoutes()
app.Log("main", "boot: routes in %s, ready in %s",
time.Since(phase).Round(time.Millisecond), time.Since(started).Round(time.Millisecond))
diff --git a/internal/x402/x402.go b/internal/x402/x402.go
index 3d034af4..bf820461 100644
--- a/internal/x402/x402.go
+++ b/internal/x402/x402.go
@@ -916,6 +916,15 @@ func TopUpRequirement(credits int) *PaymentRequirements {
return &r
}
+// VerifySigned checks an authorization without broadcasting it.
+func VerifySigned(hdr string, req *PaymentRequirements) error {
+ payload, err := signedPayload(hdr, req)
+ if err != nil {
+ return err
+ }
+ return verifyRequirement(payload, req)
+}
+
// SettleSigned verifies and settles a payment this instance signed itself.
//
// The rest of this file settles payments that arrived: an X-PAYMENT header on
@@ -928,6 +937,14 @@ func TopUpRequirement(credits int) *PaymentRequirements {
// producing exactly one thing and this decodes it the same way an inbound
// payment is decoded.
func SettleSigned(hdr string, req *PaymentRequirements) (*SettleResponse, error) {
+ payload, err := signedPayload(hdr, req)
+ if err != nil {
+ return nil, err
+ }
+ return settleRequirement(payload, req)
+}
+
+func signedPayload(hdr string, req *PaymentRequirements) (map[string]any, error) {
if req == nil {
return nil, fmt.Errorf("no payment requirement")
}
@@ -939,5 +956,5 @@ func SettleSigned(hdr string, req *PaymentRequirements) (*SettleResponse, error)
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, fmt.Errorf("payment payload is not JSON: %w", err)
}
- return settleRequirement(payload, req)
+ return payload, nil
}