Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,16 @@ jobs:
grep -q 'lexe.create_invoice' /tmp/tools.json
grep -q 'lexe.pay' /tmp/tools.json
grep -q 'lexe.analyze' /tmp/tools.json
grep -q 'lexe.list_payments' /tmp/tools.json

curl -sf -m 3 -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"lexe.pay","arguments":{"invoice":"lnbc1ci"}}}' \
http://127.0.0.1:18010/mcp | tee /tmp/pay.json
grep -q 'missing Lexe identity' /tmp/pay.json
grep -q '"isError":true' /tmp/pay.json

curl -sf -m 3 -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"lexe.list_payments","arguments":{"summary":true,"direction":"inbound","status":"completed","payer_note":"OCEAN lightning payout"}}}' \
http://127.0.0.1:18010/mcp | tee /tmp/list.json
grep -q 'missing Lexe identity' /tmp/list.json
grep -q '"isError":true' /tmp/list.json
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,15 @@ npx @modelcontextprotocol/inspector --transport http --url https://emailagent.he
| `lexe.pay` | `invoice` or `offer`, `amount_sats?`, `note?`, `proof_note?` | `{settled, index, payment}` and, for settled offer pays, `proof` (`lnp1…`) + `proof_url` (`https://lnproof.space/lnp1…`) |
| `lexe.analyze` | `invoice` or `offer` | local decode: amount/kind, no send, no identity needed |
| `lexe.check_payment` | `index` | `{settled, payment}` |
| `lexe.list_payments` | `direction?`, `status?`, `since?`, `until?`, `payer_note?`, `summary?`, `limit?`, `start_index?` | read-only history. Each payment has `index`, `id`, `direction`, `status`, `amount_sats`, `fees`, `created_at`, `finalized_at`, `payer_note`, `note`. `summary: true` returns `count` and summed `amount_sats` |
| `lexe.node_health` | — | node info (version, balances, channels) over the gateway |

`lexe.list_payments` only `GET`s `/user/v1/payments/updated`. It never
calls a pay endpoint. `payer_note` matches `payer_note` or `note`
case-insensitively. `since`/`until` apply to `created_at`. Summary
mode (`summary: true` with `direction: inbound`, `status: completed`,
`payer_note: "OCEAN lightning payout"`) totals those inbound payouts.

`lexe.pay` analyzes locally first, refuses on-chain and LNURL, then calls
the node `pay_invoice` / `pay_offer` and polls `payments/id` /
`payments/updated` (250 ms → 4 s backoff, ~150 s cap) until
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ MCP client ── Streamable HTTP ──▶ lexe-mcp ── TLS+CONNECT+mTLS ─
| `lexe.pay` | Pay a BOLT11 invoice (`lnbc…`) or BOLT12 offer (`lno1…`). Settled offer pays also return a payer proof + `https://lnproof.space/lnp1…` |
| `lexe.analyze` | Decode an invoice/offer locally without sending |
| `lexe.check_payment` | Look up a payment by index; report settled or not |
| `lexe.list_payments` | Read-only payment history (filters + optional amount summary). Never sends |
| `lexe.node_health` | Your node's info (version, balances, channels) over the gateway |

Hosted instance: [`https://emailagent.hedwig.sh/mcp`](https://emailagent.hedwig.sh/mcp).
Expand Down Expand Up @@ -174,6 +175,40 @@ send `Authorization: Bearer` per request. Optional env: `LEXE_NETWORK`,
`LEXE_PAY_MAX_SATS`, and `LEXE_CLIENT_CREDENTIALS` (single-user fallback —
pass it at runtime, do not bake it into an image).

## Payment history

`lexe.list_payments` pages `GET /user/v1/payments/updated` and does not
pay or move funds. Amounts are sats strings (fractional sats allowed).
Pass `summary: true` to total matches instead of listing them.

OCEAN pool payouts — inbound, completed, payer note contains
`OCEAN lightning payout`:

```json
{
"direction": "inbound",
"status": "completed",
"payer_note": "OCEAN lightning payout",
"summary": true
}
```

```json
{
"ok": true,
"count": 2,
"amount_sats": "3000.5",
"scanned": 5,
"truncated": false
}
```

`payer_note` is a case-insensitive substring of the payer note or the
personal note. `since` / `until` filter on `created_at` (unix
milliseconds, RFC3339, or `YYYY-MM-DD`). A list response includes
`next_start_index` when more history remains; pass it back as
`start_index`.

## systemd (user unit, no sudo)

```bash
Expand Down
235 changes: 233 additions & 2 deletions gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"math/big"
"net"
"net/http"
"strconv"
"strings"
"sync"
"testing"
Expand All @@ -34,6 +35,11 @@ type fakeStack struct {
payPolls int
lastBody map[string]any
lastPath string
reqs []string
// history, when set, is the full payment list in updated_at order.
// GET /user/v1/payments/updated pages it. The invoice-poll prefix
// still returns the single pay_invoice fixture.
history []any
}

func (f *fakeStack) record(r *http.Request) {
Expand Down Expand Up @@ -89,13 +95,36 @@ func newFakeStack(t *testing.T) *fakeStack {
})
nodeMux.HandleFunc("/user/v1/payments/updated", func(w http.ResponseWriter, r *http.Request) {
start := r.URL.Query().Get("start_index")
fs.mu.Lock()
history := fs.history
fs.mu.Unlock()
if history != nil && !strings.HasPrefix(start, "u0000000000000000455-") {
limit := paymentsFetchLimit
if n, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && n > 0 {
limit = n
}
out := make([]any, 0, limit)
for _, item := range history {
idx := paymentUpdatedIndex(asMap(item))
if start != "" && (idx == "" || idx <= start) {
continue
}
out = append(out, item)
if len(out) >= limit {
break
}
}
writeTestJSON(w, map[string]any{"payments": out})
return
}
if !strings.HasPrefix(start, "u0000000000000000455-") {
writeTestJSON(w, map[string]any{"payments": []any{}})
return
}
writeTestJSON(w, map[string]any{"payments": []any{map[string]any{
"id": "ln_abc", "status": "completed", "created_at": 456,
"direction": "outbound", "kind": "invoice", "amount": "1000",
"updated_at": 456,
}}})
})
nodeMux.HandleFunc("/user/v1/payments/id", func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -132,10 +161,15 @@ func newFakeStack(t *testing.T) *fakeStack {
ephPool := x509.NewCertPool()
ephPool.AddCert(ephCA.cert)
go func() {
_ = (&http.Server{Handler: nodeMux}).Serve(tls.NewListener(nodeLn, &tls.Config{
_ = (&http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fs.mu.Lock()
fs.reqs = append(fs.reqs, r.Method+" "+r.URL.Path)
fs.mu.Unlock()
nodeMux.ServeHTTP(w, r)
})}).Serve(tls.NewListener(nodeLn, &tls.Config{
Certificates: []tls.Certificate{{Certificate: [][]byte{nodeCert.raw}, PrivateKey: nodeKey}},
ClientAuth: tls.RequireAndVerifyClientCert, // forces the client cert
ClientCAs: ephPool, // forces eph-CA-signed client
ClientCAs: ephPool, // forces eph-CA-signed client
MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1"},
}))
Expand Down Expand Up @@ -404,3 +438,200 @@ func TestMissingIdentity(t *testing.T) {
t.Fatalf("got %v", err)
}
}

func oceanHistory() []any {
return []any{
map[string]any{
"id": "ln_ocean1", "direction": "inbound", "status": "completed", "kind": "invoice",
"amount": "1000.5", "fees": "1",
"created_at": int64(1700000001000), "finalized_at": int64(1700000001500), "updated_at": int64(1700000001500),
"payer_note": "OCEAN lightning payout",
},
map[string]any{
"id": "ln_coffee", "direction": "outbound", "status": "completed", "kind": "invoice",
"amount": "21", "fees": "0",
"created_at": int64(1700000002000), "finalized_at": int64(1700000002000), "updated_at": int64(1700000002000),
"note": "coffee",
},
map[string]any{
"id": "ln_ocean2", "direction": "inbound", "status": "pending", "kind": "invoice",
"amount": "500", "fees": "0",
"created_at": int64(1700000003000), "updated_at": int64(1700000003000),
"payer_note": "OCEAN lightning payout",
},
map[string]any{
"id": "ln_ocean3", "direction": "Inbound", "status": "completed", "kind": "offer",
"amount": "2000", "fees": "2.5",
"created_at": int64(1700000004000), "finalized_at": int64(1700000004500), "updated_at": int64(1700000004500),
"message": "ocean lightning payout extra",
},
map[string]any{
"id": "ln_other", "direction": "inbound", "status": "completed", "kind": "invoice",
"amount": "9", "fee": "0",
"created_at": int64(1700000005000), "finalized_at": int64(1700000005000), "updated_at": int64(1700000005000),
"payer_note": "thanks", "personal_note": "not a pool payout",
},
map[string]any{
"id": "ln_noted", "direction": "inbound", "status": "completed", "kind": "invoice",
"amount": "0.5", "fees": "0",
"created_at": int64(1700000006000), "finalized_at": int64(1700000006000), "updated_at": int64(1700000006000),
"personal_note": "OCEAN lightning payout manual",
},
}
}

func TestListPaymentsReadOnlySummary(t *testing.T) {
withFakeStack(t, func(t *testing.T, fs *fakeStack) {
oldFetch, oldScan := paymentsFetchLimit, paymentsMaxScan
paymentsFetchLimit = 2
paymentsMaxScan = 10000
t.Cleanup(func() {
paymentsFetchLimit, paymentsMaxScan = oldFetch, oldScan
})
fs.mu.Lock()
fs.history = oceanHistory()
before := len(fs.reqs)
fs.mu.Unlock()

args := map[string]any{
"direction": "inbound",
"status": "completed",
"payer_note": "OCEAN lightning payout",
"summary": true,
}
out, err := listPayments(args, fs.blob)
if err != nil {
t.Fatal(err)
}
if out["count"] != 3 || out["amount_sats"] != "3001" {
t.Fatalf("summary = %#v", out)
}
if out["truncated"] != false {
t.Fatalf("truncated = %#v", out["truncated"])
}
if _, ok := out["next_start_index"]; ok {
t.Fatalf("history exhausted but next_start_index set: %#v", out)
}
if _, ok := out["payments"]; ok {
t.Fatal("summary mode returned payments")
}

fs.mu.Lock()
reqs := append([]string(nil), fs.reqs[before:]...)
fs.mu.Unlock()
if len(reqs) < 3 {
t.Fatalf("expected multiple pages, got %v", reqs)
}
for _, req := range reqs {
if req != "GET /user/v1/payments/updated" {
t.Fatalf("list_payments called %q; only GET payments/updated is allowed", req)
}
}

// since the third match (created_at 1700000004000) through the note-only one
args["since"] = "2023-11-14T22:13:24Z" // 1700000004000
out, err = listPayments(args, fs.blob)
if err != nil {
t.Fatal(err)
}
if out["count"] != 2 || out["amount_sats"] != "2000.5" {
t.Fatalf("since summary = %#v", out)
}
})
}

func TestListPaymentsPageAndProject(t *testing.T) {
withFakeStack(t, func(t *testing.T, fs *fakeStack) {
oldFetch := paymentsFetchLimit
paymentsFetchLimit = 2
t.Cleanup(func() { paymentsFetchLimit = oldFetch })
fs.mu.Lock()
fs.history = oceanHistory()
fs.mu.Unlock()

out, err := listPayments(map[string]any{
"direction": "inbound",
"status": "completed",
"payer_note": "ocean lightning payout",
"limit": 1,
}, fs.blob)
if err != nil {
t.Fatal(err)
}
pays, _ := out["payments"].([]map[string]any)
if len(pays) != 1 || out["count"] != 1 {
t.Fatalf("first page = %#v", out)
}
p := pays[0]
if p["id"] != "ln_ocean1" || p["direction"] != "inbound" || p["status"] != "completed" {
t.Fatalf("payment = %#v", p)
}
if p["amount_sats"] != "1000.5" || p["fees"] != "1" {
t.Fatalf("amounts = %#v", p)
}
if p["payer_note"] != "OCEAN lightning payout" || p["note"] != "" {
t.Fatalf("notes = %#v", p)
}
if p["created_at"] != int64(1700000001000) || p["finalized_at"] != int64(1700000001500) {
t.Fatalf("times = %#v", p)
}
wantIdx := paymentIndex(1700000001000, "ln_ocean1")
if p["index"] != wantIdx {
t.Fatalf("index = %v want %s", p["index"], wantIdx)
}
next := asString(out["next_start_index"])
if next == "" || !strings.HasPrefix(next, "u") {
t.Fatalf("next_start_index = %q", next)
}

rest, err := listPayments(map[string]any{
"direction": "inbound",
"status": "completed",
"payer_note": "OCEAN lightning payout",
"start_index": next,
}, fs.blob)
if err != nil {
t.Fatal(err)
}
restPays, _ := rest["payments"].([]map[string]any)
if rest["count"] != 2 || len(restPays) != 2 {
t.Fatalf("rest = %#v", rest)
}
if restPays[0]["id"] != "ln_ocean3" || restPays[0]["payer_note"] != "ocean lightning payout extra" {
t.Fatalf("alias message not projected: %#v", restPays[0])
}
if restPays[1]["id"] != "ln_noted" || restPays[1]["note"] != "OCEAN lightning payout manual" {
t.Fatalf("personal_note not projected: %#v", restPays[1])
}
})
}

func TestListPaymentsScanCap(t *testing.T) {
withFakeStack(t, func(t *testing.T, fs *fakeStack) {
oldFetch, oldScan := paymentsFetchLimit, paymentsMaxScan
paymentsFetchLimit = 2
paymentsMaxScan = 3
t.Cleanup(func() {
paymentsFetchLimit, paymentsMaxScan = oldFetch, oldScan
})
fs.mu.Lock()
fs.history = oceanHistory()
fs.mu.Unlock()

out, err := listPayments(map[string]any{"summary": true}, fs.blob)
if err != nil {
t.Fatal(err)
}
if out["truncated"] != true || out["scanned"] != 3 {
t.Fatalf("cap = %#v", out)
}
if asString(out["next_start_index"]) == "" {
t.Fatal("expected next_start_index when the scan cap stops early")
}
// Unfiltered summary of the first three rows: 1000.5 + 21 + 500.
// ocean3 (2000) is past the cap.
if out["count"] != 3 || out["amount_sats"] != "1521.5" {
t.Fatalf("partial summary = %#v", out)
}
})
}
Loading
Loading