Skip to content

Commit 593aaa2

Browse files
realbubclaude
andcommitted
Initial release of LnBot.L402
L402 Lightning payment middleware for .NET: - LnBot.L402: Client-side HttpClient handler with auto-pay, token caching, and budget tracking - LnBot.L402.AspNetCore: Server-side middleware, [L402] attribute, and endpoint filter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
0 parents  commit 593aaa2

34 files changed

Lines changed: 1932 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
build:
10+
runs-on: ubuntu-latest
11+
strategy:
12+
matrix:
13+
dotnet-version: ["8.0.x", "9.0.x"]
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- name: Setup .NET ${{ matrix.dotnet-version }}
18+
uses: actions/setup-dotnet@v4
19+
with:
20+
dotnet-version: ${{ matrix.dotnet-version }}
21+
22+
- name: Restore
23+
run: dotnet restore
24+
25+
- name: Build
26+
run: dotnet build --no-restore --warnaserror
27+
28+
- name: Test
29+
run: dotnet test --no-build --verbosity normal

.github/workflows/release.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: Release
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
jobs:
8+
publish:
9+
runs-on: ubuntu-latest
10+
permissions:
11+
contents: read
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Setup .NET
16+
uses: actions/setup-dotnet@v4
17+
with:
18+
dotnet-version: 8.0.x
19+
20+
- name: Test
21+
run: dotnet test --verbosity normal
22+
23+
- name: Pack LnBot.L402
24+
run: dotnet pack src/LnBot.L402/LnBot.L402.csproj -c Release -o ./nupkg
25+
26+
- name: Pack LnBot.L402.AspNetCore
27+
run: dotnet pack src/LnBot.L402.AspNetCore/LnBot.L402.AspNetCore.csproj -c Release -o ./nupkg
28+
29+
- name: Publish to NuGet
30+
run: dotnet nuget push ./nupkg/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
bin/
2+
obj/
3+
*.user
4+
*.suo
5+
.vs/
6+
*.DotSettings.user

README.md

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
# LnBot.L402
2+
3+
[![NuGet](https://img.shields.io/nuget/v/LnBot.L402)](https://www.nuget.org/packages/LnBot.L402)
4+
[![NuGet](https://img.shields.io/nuget/v/LnBot.L402.AspNetCore)](https://www.nuget.org/packages/LnBot.L402.AspNetCore)
5+
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
6+
7+
**L402 Lightning payment middleware for .NET** — paywall any API in one line. Built on [ln.bot](https://ln.bot).
8+
9+
Two NuGet packages:
10+
11+
- **`LnBot.L402`** — Client-side. Auto-pay L402-protected APIs with any `HttpClient`. Works in console apps, background services, MAUI — anything with `HttpClient`.
12+
- **`LnBot.L402.AspNetCore`** — Server-side. Protect ASP.NET Core routes behind L402 paywalls with middleware, `[L402]` attributes, or endpoint filters.
13+
14+
Both packages are thin glue layers. All L402 logic — macaroon creation, signature verification, preimage checking — lives in the [ln.bot API](https://ln.bot/docs) via the [`LnBot` SDK](https://www.nuget.org/packages/LnBot). Zero crypto dependencies.
15+
16+
---
17+
18+
## What is L402?
19+
20+
[L402](https://github.com/lightninglabs/L402) is a protocol built on HTTP `402 Payment Required`. It enables machine-to-machine micropayments over the Lightning Network:
21+
22+
1. **Client** requests a protected resource
23+
2. **Server** returns `402` with a Lightning invoice and a macaroon token
24+
3. **Client** pays the invoice, obtains the preimage as proof of payment
25+
4. **Client** retries the request with `Authorization: L402 <macaroon>:<preimage>`
26+
5. **Server** verifies the token and grants access
27+
28+
L402 is ideal for API monetization, AI agent tool access, pay-per-request data feeds, and any scenario where you want instant, permissionless, per-request payments without subscriptions or API key provisioning.
29+
30+
---
31+
32+
## Install
33+
34+
```bash
35+
dotnet add package LnBot.L402.AspNetCore # Server (includes client package)
36+
dotnet add package LnBot.L402 # Client only (no ASP.NET Core dependency)
37+
```
38+
39+
---
40+
41+
## Server — Protect Routes with L402
42+
43+
### Middleware pipeline
44+
45+
```csharp
46+
using LnBot;
47+
using LnBot.L402.AspNetCore;
48+
49+
var builder = WebApplication.CreateBuilder(args);
50+
builder.Services.AddSingleton(new LnBotClient("key_..."));
51+
52+
var app = builder.Build();
53+
54+
app.UseL402Paywall("/api/premium", new L402Options
55+
{
56+
Price = 10,
57+
Description = "API access",
58+
});
59+
60+
app.MapGet("/api/premium/data", () => Results.Ok(new { data = "premium content" }));
61+
app.MapGet("/api/free/health", () => Results.Ok(new { status = "ok" }));
62+
63+
app.Run();
64+
```
65+
66+
### Controller attribute
67+
68+
```csharp
69+
[ApiController]
70+
[Route("api/[controller]")]
71+
public class WeatherController : ControllerBase
72+
{
73+
[L402(Price = 50, Description = "Weather forecast")]
74+
[HttpGet("forecast")]
75+
public IActionResult GetForecast()
76+
=> Ok(new { forecast = "sunny" });
77+
}
78+
```
79+
80+
### Minimal API endpoint filter
81+
82+
```csharp
83+
app.MapGet("/api/premium/data", () => Results.Ok(new { data = "premium" }))
84+
.AddEndpointFilter(new L402EndpointFilter(price: 10, description: "API access"));
85+
```
86+
87+
### Dynamic pricing
88+
89+
```csharp
90+
app.UseL402Paywall("/api/dynamic", new L402Options
91+
{
92+
PriceFactory = context =>
93+
{
94+
if (context.Request.Path.StartsWithSegments("/api/dynamic/bulk"))
95+
return Task.FromResult(50);
96+
return Task.FromResult(5);
97+
}
98+
});
99+
```
100+
101+
---
102+
103+
## Client — Auto-Pay L402 APIs
104+
105+
### With ASP.NET Core DI
106+
107+
```csharp
108+
var builder = WebApplication.CreateBuilder(args);
109+
builder.Services.AddSingleton(new LnBotClient("key_..."));
110+
111+
builder.Services.AddHttpClient("paid-apis")
112+
.AddL402Handler(new L402ClientOptions
113+
{
114+
MaxPrice = 100,
115+
BudgetSats = 50_000,
116+
BudgetPeriod = BudgetPeriod.Day,
117+
});
118+
119+
var app = builder.Build();
120+
121+
app.MapGet("/proxy", async (IHttpClientFactory factory) =>
122+
{
123+
var http = factory.CreateClient("paid-apis");
124+
var data = await http.GetStringAsync("https://api.example.com/premium/data");
125+
return Results.Ok(data);
126+
});
127+
```
128+
129+
### Console app (no ASP.NET Core)
130+
131+
```csharp
132+
using LnBot;
133+
using LnBot.L402;
134+
using Microsoft.Extensions.DependencyInjection;
135+
136+
var services = new ServiceCollection();
137+
services.AddSingleton(new LnBotClient("key_..."));
138+
services.AddSingleton<ITokenStore, MemoryTokenStore>();
139+
services.AddHttpClient("paid-apis").AddL402Handler();
140+
141+
var provider = services.BuildServiceProvider();
142+
var http = provider.GetRequiredService<IHttpClientFactory>().CreateClient("paid-apis");
143+
144+
// Auto-pays any 402 responses transparently
145+
var response = await http.GetStringAsync("https://api.example.com/premium/data");
146+
Console.WriteLine(response);
147+
```
148+
149+
---
150+
151+
## Header Utilities
152+
153+
```csharp
154+
using LnBot.L402;
155+
156+
// Parse Authorization: L402 <macaroon>:<preimage>
157+
var auth = L402Headers.ParseAuthorization("L402 mac_base64:preimage_hex");
158+
// → (Macaroon: "mac_base64", Preimage: "preimage_hex")
159+
160+
// Parse WWW-Authenticate: L402 macaroon="...", invoice="..."
161+
var challenge = L402Headers.ParseChallenge("L402 macaroon=\"abc\", invoice=\"lnbc1...\"");
162+
// → (Macaroon: "abc", Invoice: "lnbc1...")
163+
164+
// Format headers
165+
L402Headers.FormatAuthorization("mac", "pre"); // → "L402 mac:pre"
166+
L402Headers.FormatChallenge("mac", "lnbc1..."); // → "L402 macaroon=\"mac\", invoice=\"lnbc1...\""
167+
```
168+
169+
---
170+
171+
## Custom Token Store
172+
173+
Implement `ITokenStore` for Redis, file system, or any persistence layer:
174+
175+
```csharp
176+
public class RedisTokenStore : ITokenStore
177+
{
178+
public Task<L402Token?> GetAsync(string url) { /* ... */ }
179+
public Task SetAsync(string url, L402Token token) { /* ... */ }
180+
public Task DeleteAsync(string url) { /* ... */ }
181+
}
182+
183+
// Register in DI
184+
services.AddSingleton<ITokenStore, RedisTokenStore>();
185+
```
186+
187+
---
188+
189+
## How It Works
190+
191+
**Server middleware** makes two SDK calls:
192+
- `client.L402.CreateChallengeAsync()` — creates an invoice + macaroon when a client needs to pay
193+
- `client.L402.VerifyAsync()` — verifies an L402 authorization token when a client presents one
194+
195+
**Client handler** makes one SDK call:
196+
- `client.L402.PayAsync()` — pays a Lightning invoice and returns a ready-to-use Authorization header
197+
198+
---
199+
200+
## Requirements
201+
202+
- **.NET 8+**
203+
- An [ln.bot](https://ln.bot) API key — [create a wallet](https://ln.bot/docs) to get one
204+
205+
## Related packages
206+
207+
- [`LnBot`](https://www.nuget.org/packages/LnBot) — The .NET SDK this package is built on
208+
- [`@lnbot/l402`](https://www.npmjs.com/package/@lnbot/l402) — TypeScript/Express.js equivalent
209+
- [`@lnbot/sdk`](https://www.npmjs.com/package/@lnbot/sdk) — TypeScript SDK
210+
211+
## Links
212+
213+
- [ln.bot](https://ln.bot) — website
214+
- [Documentation](https://ln.bot/docs)
215+
- [L402 specification](https://github.com/lightninglabs/L402)
216+
- [GitHub](https://github.com/lnbotdev)
217+
218+
## License
219+
220+
MIT

csharp-l402.slnx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<Solution>
2+
<Folder Name="/src/">
3+
<Project Path="src/LnBot.L402.AspNetCore/LnBot.L402.AspNetCore.csproj" />
4+
<Project Path="src/LnBot.L402/LnBot.L402.csproj" />
5+
</Folder>
6+
<Folder Name="/tests/">
7+
<Project Path="tests/LnBot.L402.AspNetCore.Tests/LnBot.L402.AspNetCore.Tests.csproj" />
8+
<Project Path="tests/LnBot.L402.Tests/LnBot.L402.Tests.csproj" />
9+
</Folder>
10+
</Solution>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Microsoft.AspNetCore.Mvc.Filters;
3+
using Microsoft.Extensions.DependencyInjection;
4+
5+
namespace LnBot.L402.AspNetCore;
6+
7+
/// <summary>
8+
/// Protects a controller action with an L402 paywall.
9+
/// Requires LnBotClient to be registered in DI.
10+
/// </summary>
11+
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
12+
public class L402Attribute : Attribute, IAsyncActionFilter
13+
{
14+
public int Price { get; set; }
15+
public string? Description { get; set; }
16+
17+
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
18+
{
19+
var client = context.HttpContext.RequestServices.GetRequiredService<LnBotClient>();
20+
21+
if (await L402Handler.HandleAsync(client, context.HttpContext, Price, Description))
22+
{
23+
await next();
24+
}
25+
else
26+
{
27+
// L402Handler already wrote the 402 response body.
28+
// Set an empty result to prevent MVC from overwriting it.
29+
context.Result = new EmptyResult();
30+
}
31+
}
32+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using Microsoft.AspNetCore.Http;
2+
using Microsoft.Extensions.DependencyInjection;
3+
4+
namespace LnBot.L402.AspNetCore;
5+
6+
/// <summary>
7+
/// Endpoint filter for minimal APIs that adds L402 paywall protection.
8+
/// </summary>
9+
public class L402EndpointFilter : IEndpointFilter
10+
{
11+
private readonly int _price;
12+
private readonly string? _description;
13+
14+
public L402EndpointFilter(int price, string? description = null)
15+
{
16+
_price = price;
17+
_description = description;
18+
}
19+
20+
public async ValueTask<object?> InvokeAsync(
21+
EndpointFilterInvocationContext context,
22+
EndpointFilterDelegate next)
23+
{
24+
var httpContext = context.HttpContext;
25+
var client = httpContext.RequestServices.GetRequiredService<LnBotClient>();
26+
27+
if (await L402Handler.HandleAsync(client, httpContext, _price, _description))
28+
{
29+
return await next(context);
30+
}
31+
32+
// L402Handler already wrote the 402 response — return empty to avoid double-write
33+
return Results.Empty;
34+
}
35+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.AspNetCore.Http;
3+
using Microsoft.Extensions.DependencyInjection;
4+
5+
namespace LnBot.L402.AspNetCore;
6+
7+
public static class L402Extensions
8+
{
9+
/// <summary>
10+
/// Adds L402 paywall middleware that protects all routes under the given path prefix.
11+
/// Requires LnBotClient to be registered in DI.
12+
/// </summary>
13+
public static IApplicationBuilder UseL402Paywall(
14+
this IApplicationBuilder app,
15+
string path,
16+
L402Options options)
17+
{
18+
var client = app.ApplicationServices.GetRequiredService<LnBotClient>();
19+
return app.UseMiddleware<L402Middleware>(client, options, new PathString(path));
20+
}
21+
}

0 commit comments

Comments
 (0)