diff --git a/.claude/commands/check.md b/.claude/commands/check.md new file mode 100644 index 0000000..22df204 --- /dev/null +++ b/.claude/commands/check.md @@ -0,0 +1,17 @@ +--- +description: Run the full quality suite and fix everything that fails +allowed-tools: Bash, Read, Edit, Grep, Glob +--- + +Run `composer test-all` (php-cs-fixer dry-run, Psalm, PHPStan, PHPUnit, Rector dry-run). + +If anything fails: + +1. Fix the root cause in the source, not by widening ignores in `phpstan.neon` / `psalm.xml`. +2. Auto-fixable style/Rector diffs: run `composer fix`, then review the diff. +3. PHPStan OOM inside `resultCache.php` = stale cache -> `vendor/bin/phpstan clear-result-cache`. +4. Re-run `composer test-all` until green. + +Report: what failed, what you changed, final suite status. Do not commit unless asked. + +$ARGUMENTS diff --git a/.claude/commands/module.md b/.claude/commands/module.md new file mode 100644 index 0000000..0bc9bf9 --- /dev/null +++ b/.claude/commands/module.md @@ -0,0 +1,23 @@ +--- +description: Scaffold a new Gacela module following this repo's layout +allowed-tools: Bash, Read, Write, Edit, Grep, Glob +argument-hint: [what it does] +--- + +Create module `$1` in `src/$1/`, mirroring `src/Invoice/`: + +- `$1Facade.php` — `/** @extends AbstractFacade<$1Factory> */`, thin delegation only. +- `$1Factory.php` — `/** @extends AbstractFactory<$1Config> */`, builds Application/Domain objects. +- `$1Config.php` — extends `AbstractConfig`, typed getters over `$this->get(...)`. +- `$1DependencyProvider.php` — only if external deps are needed; + `/** @extends AbstractProvider<$1Config> */`, constants as `public const string`. +- `Application/`, `Domain/`, `Infrastructure/` as needed; Domain talks to interfaces only. + +Rules: `declare(strict_types=1);`, `final` classes, `readonly` where stateless, DTOs in +`src/Shared/Transfer/` with the `Transfer` suffix. Cross-module access goes through the +other module's Facade. + +Add unit tests under `tests/Unit/$1/` mirroring the source paths; add a Feature test only +if the module needs Gacela bootstrapping. Finish with `composer test-all`. + +Context: $ARGUMENTS diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..19a728d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "includeCoAuthoredBy": false, + "env": { + "XDEBUG_MODE": "off" + }, + "permissions": { + "allow": [ + "Bash(composer test:*)", + "Bash(composer test-all:*)", + "Bash(composer quality:*)", + "Bash(composer phpunit:*)", + "Bash(composer psalm:*)", + "Bash(composer phpstan:*)", + "Bash(composer csrun:*)", + "Bash(composer csfix:*)", + "Bash(composer fix:*)", + "Bash(composer rector:*)", + "Bash(composer ctal:*)", + "Bash(composer static-clear-cache:*)", + "Bash(composer test-coverage:*)", + "Bash(composer validate:*)", + "Bash(composer outdated:*)", + "Bash(composer show:*)", + "Bash(composer dump-autoload:*)", + "Bash(vendor/bin/phpunit:*)", + "Bash(vendor/bin/psalm:*)", + "Bash(vendor/bin/phpstan:*)", + "Bash(vendor/bin/php-cs-fixer:*)", + "Bash(vendor/bin/rector:*)", + "Bash(php -v)", + "Bash(php -r:*)", + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git show:*)", + "Bash(gh pr view:*)", + "Bash(gh pr diff:*)", + "Bash(gh issue view:*)" + ], + "ask": [ + "Bash(composer update:*)", + "Bash(composer require:*)", + "Bash(composer remove:*)", + "Bash(git push:*)", + "Bash(gh pr create:*)" + ], + "deny": [ + "Read(./lightning-config.php)", + "Read(./nostr.json)", + "Read(./.env)", + "Read(./.env.*)" + ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e94027..b8fff73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: - php-version: 8.2 + php-version: 8.3 coverage: none tools: composer @@ -47,7 +47,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: - php-version: 8.2 + php-version: 8.3 coverage: none tools: composer @@ -78,7 +78,7 @@ jobs: fail-fast: false matrix: dependencies: [ "locked", "highest" ] - php-version: [ "8.2" , "8.2", "8.3" ] + php-version: [ "8.3", "8.4", "8.5" ] operating-system: - "ubuntu-latest" steps: diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 8eda383..941702a 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -1,6 +1,6 @@ build: environment: - php: 8.2 + php: 8.3 nodes: analysis: project_setup: @@ -14,7 +14,7 @@ build: tests: override: - - # xdebug is installed but mode=off; PHPUnit 10 exits 1 when coverage + # xdebug is installed but mode=off; PHPUnit exits 1 when coverage # is requested without a driver, so enable it explicitly. command: 'XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-clover=coverage.clover --configuration phpunit.xml' coverage: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f55685..27c95d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project are documented in this file. ## [Unreleased] +- **BREAKING**: require PHP >= 8.3 (PHPUnit 12 and the Rector php83 set need it) +- chore: update dependencies to their latest majors — gacela 1.21, PHPUnit 12.5, + Rector 2.5, php-cs-fixer 3.95, Psalm 6.16, symfony/http-client 7.4 +- chore: run CI on PHP 8.3, 8.4 and 8.5 (the matrix listed 8.2 twice) +- docs: split the README into `docs/` (getting started, configuration, HTTP API, + architecture, development) + **Full Changelog**: https://github.com/php-lightning/lnaddress/compare/0.9.0...main ## [0.9.0] - 2026-07-23 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5de7c9d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,78 @@ +# lnaddress + +PHP library serving a [Lightning Address](https://lightningaddress.com/) endpoint +(LNURL-pay / LUD-06). Built on [Gacela](https://gacela-project.com) (modular monolith) + +`gacela-project/router`. PHP >= 8.3, PSR-4 `PhpLightning\` -> `src/`, +`PhpLightningTest\` -> `tests/`. + +Full docs live in [`docs/`](docs/) — architecture, config reference, HTTP API. + +## Request flow + +`public/index.php` bootstraps Gacela (`gacela.php`) -> `Router` -> `InvoiceRoutesPlugin` +(route + `CorsMiddleware` + `InvoiceExceptionHandler`) -> `InvoiceController` -> +`InvoiceFacade`. + +- `GET /{username}` without `amount` -> `getCallbackUrl()` -> LNURL-pay params +- `GET /{username}?amount=` -> `generateInvoice()` -> bolt11 under `pr` +- Errors bubble to `InvoiceExceptionHandler`, which returns `{status: ERROR, reason: …}` — + controllers do not try/catch. + +Config comes from `lightning-config.dist.php` (overridable by gitignored +`lightning-config.php`) plus users/backends in `backends.json`. + +## Module layout (Gacela) + +Modules live in `src//`. A module's public API is its **Facade** — never reach +into another module's Application/Domain/Infrastructure. + +``` +src/Invoice/ + InvoiceFacade.php entry point; @extends AbstractFacade + InvoiceFactory.php wiring; @extends AbstractFactory + InvoiceConfig.php typed config reads + InvoiceDependencyProvider.php external deps (HTTP_API) + Application/ use cases: CallbackUrl, InvoiceGenerator + Domain/ pure logic + interfaces (BackendInvoice, CallbackUrl, Http) + Infrastructure/ Controller, Handler, Middleware, Http, Plugin +src/Config/ LightningConfig builder, BackendsConfig, BackendType enum +src/Shared/ ConfigKey, Transfer DTOs, Value objects +``` + +Gacela generics matter: `AbstractFacade`/`AbstractFactory`/`AbstractProvider` are +templated, so annotate with `@extends ...`. For docblock service resolution use +`ServiceResolverAwareTrait` (`DocBlockResolverAwareTrait` is deprecated). + +Config keys are constants in `PhpLightning\Shared\Config\ConfigKey`, shared by the writer +(`LightningConfig`) and reader (`InvoiceConfig`) — add new keys there, not as literals. + +## Conventions + +- `declare(strict_types=1);` everywhere; classes `final`, `readonly` when stateless. +- Typed class constants (`public const string FOO = '…';`) — enforced by the Rector php83 set. +- Domain depends on interfaces; concrete wiring lives in the Factory / DependencyProvider. +- DTO suffixes: `Transfer` for data flowing into operations, `Result` for handler output. + No `T` prefix in this repo. See `src/Shared/Transfer/`. +- Conventional commits, `ref:` instead of `refactor:`. + +## Testing + +- `tests/Unit/` mirrors `src/` — plain PHPUnit, no bootstrap. +- `tests/Feature/` boots Gacela and overrides `InvoiceDependencyProvider` to inject + `FakeHttpApi`; fixtures in `tests/Feature/backends.json`. +- PHPUnit 12: attributes, `createStub()`/`createMock()`, no `withConsecutive`. + +## Commands + +```bash +composer test-all # quality (cs, psalm, phpstan) + phpunit + rector --dry-run +composer quality # csrun + psalm + phpstan +composer phpunit # tests only +composer fix # php-cs-fixer + rector (writes changes) +composer ctal # clear static caches, fix, then full suite +composer serve # php -S localhost:8080 public/index.php +``` + +Psalm runs at errorLevel 1, PHPStan at level max. If PHPStan dies with an OOM inside +`resultCache.php`, that is a stale cache — run `vendor/bin/phpstan clear-result-cache`, +not a bigger memory limit. diff --git a/README.md b/README.md index 0e78877..38eb617 100644 --- a/README.md +++ b/README.md @@ -13,187 +13,86 @@ Psalm Type-coverage Status - + MIT Software License

-Self-host your own [Lightning Address](https://lightningaddress.com) in PHP: a human-readable identifier like `you@yourdomain.com` that any Lightning wallet can pay. It implements [LNURL-pay (LUD-06)](https://github.com/lnurl/luds/blob/luds/06.md) and is backend-agnostic — [LNbits](https://lnbits.com) is the backend available today. Built on the [Gacela](https://gacela-project.com) framework. +Self-host your own [Lightning Address](https://lightningaddress.com) in PHP: a +human-readable identifier like `you@yourdomain.com` that any Lightning wallet can pay. It +implements [LNURL-pay (LUD-06)](https://github.com/lnurl/luds/blob/luds/06.md) and is +backend-agnostic — [LNbits](https://lnbits.com) is the backend available today. Built on +the [Gacela](https://gacela-project.com) framework. -## Requirements +## Quick start -- PHP >= 8.2 - -## Install +Requires PHP >= 8.3 and an LNbits wallet API key. ```bash -composer require php-lightning/lnaddress +composer require php-lightning/lnaddress # or clone this repo + composer install +cp lightning-config.dist.php lightning-config.php # settings +cp backends.dist.json backends.json # per-user invoice backends +composer serve # http://localhost:8080 ``` -`composer install` runs a post-install step that copies `backends.dist.json` → `backends.json` if the latter does not exist yet. - -Prefer starting from a working project? Use the ready-made [demo template](https://github.com/php-lightning/demo-lnaddress). It depends on this library, so a `composer update` pulls in new features and fixes as they land here. - -## Configure - -There are two config files: `lightning-config.php` (settings) and `backends.json` (per-user invoice backends). - -### 1. Settings — `lightning-config.php` - ```bash -cp lightning-config.dist.php lightning-config.php +curl 'http://localhost:8080/bob' # → LNURL-pay params +curl 'http://localhost:8080/bob?amount=2000' # → bolt11 invoice (2000 millisats) ``` -`LightningConfig` has a fluent API: - ```php +// lightning-config.php use PhpLightning\Config\LightningConfig; return (new LightningConfig()) ->setDomain('yourdomain.com') ->setReceiver('default-receiver') - ->setDescriptionTemplate('Pay to %s') // %s = the lightning address + ->setDescriptionTemplate('Pay to %s') // %s = the lightning address ->setSuccessMessage('Thanks for the payment!') - ->setInvoiceMemo('') ->setSendableRange(min: 100_000, max: 10_000_000_000) // millisats ->setCallbackUrl('https://yourdomain.com') ->addBackendsFile(getcwd() . '/backends.json'); ``` -### 2. Backends — `backends.json` - -```bash -cp backends.dist.json backends.json -``` - -Each username maps to its own invoice backend: - ```json +// backends.json { "bob": { "type": "lnbits", "api_key": "abc...123", "api_endpoint": "http://localhost:5000" }, "alice": { "type": "lnbits", "api_key": "def...456", "api_endpoint": "http://localhost:5000" } } ``` -### Register backends programmatically (no JSON file) +Wallets resolve `bob@yourdomain.com` through +`https://yourdomain.com/.well-known/lnurlp/bob`, so route that path to this app's +`/{username}` over HTTPS. -You can skip `backends.json` and register backends directly in `lightning-config.php`: +## Documentation -```php -use PhpLightning\Config\Backend\LnBitsBackendConfig; +| Guide | Contents | +|---|---| +| [Getting started](docs/getting-started.md) | Install, configure, run, deploy | +| [Configuration](docs/configuration.md) | Every setter, defaults, backends file | +| [HTTP API](docs/api.md) | Routes, payloads, CORS, error objects | +| [Architecture](docs/architecture.md) | Modules, layers, adding a backend | +| [Development](docs/development.md) | Scripts, tests, static analysis, releasing | -$config->addBackend('bob', LnBitsBackendConfig::withEndpointAndKey('http://localhost:5000', 'abc...123')); -``` +Also: [CHANGELOG](CHANGELOG.md) · +[wiki](https://github.com/php-lightning/lnaddress/wiki) · +[demo template](https://github.com/php-lightning/demo-lnaddress) -## Run the server +## Development ```bash -composer serve +composer test-all # php-cs-fixer + psalm + phpstan + phpunit + rector (dry-run) +composer fix # apply php-cs-fixer and rector changes ``` -This starts `php -S localhost:8080 public/index.php`. - -## HTTP API - -One route serves the full LNURL-pay flow: `GET /{username?}`. The username is optional — when omitted, the request resolves to the default `receiver@domain` from your config. - -Every response carries permissive CORS headers (`Access-Control-Allow-Origin: *`) so browser-based wallets can call it, and `OPTIONS` preflight requests are answered directly. Uncaught errors are turned into the LNURL error object by a global handler. - -### Step 1 — pay params - -`GET /bob` (no `amount`) returns the LNURL-pay parameters: - -```json -{ - "callback": "https://yourdomain.com", - "maxSendable": 10000000000, - "minSendable": 100000, - "metadata": "[[\"text/plain\",\"Pay to bob@yourdomain.com\"],[\"text/identifier\",\"bob@yourdomain.com\"]]", - "tag": "payRequest", - "commentAllowed": false -} -``` - -### Step 2 — invoice - -`GET /bob?amount=` returns a bolt11 invoice for that amount: - -```json -{ - "pr": "lnbc20n1p...", - "status": "OK", - "memo": "", - "successAction": { "tag": "message", "message": "Thanks for the payment!" }, - "routes": [], - "disposable": false, - "error": null -} -``` - -### Errors - -Failures return an LNURL error object, for example when the amount falls outside the sendable range or the backend is unreachable: - -```json -{ "status": "ERROR", "reason": "Amount is not between minimum and maximum sendable amount" } -``` - -> **Units:** the sendable range and the `amount` query param are in **millisats**. The backend is billed in **sats** (`millisats / 1000`). - -## Use as a library (programmatic) - -You can call the facade directly instead of going over HTTP: - -```php -use Gacela\Framework\Gacela; -use PhpLightning\Invoice\InvoiceFacade; - -Gacela::bootstrap(__DIR__); - -$facade = new InvoiceFacade(); -$payParams = $facade->getCallbackUrl('bob'); // LNURL-pay params -$invoice = $facade->generateInvoice('bob', 2_000); // millisats -``` - -## Configuration reference - -| Setter | Purpose | Default | -| --- | --- | --- | -| `setDomain(string)` | Your domain (URL scheme is stripped) | — | -| `setReceiver(string)` | Default username when none is in the URL | — | -| `setSendableRange(int $min, int $max)` | Allowed amounts, in millisats | `100_000` – `10_000_000_000` | -| `setCallbackUrl(string)` | Public callback base URL wallets call back to | — | -| `setDescriptionTemplate(string)` | LNURL metadata description (`%s` = the address) | `Pay to %s` | -| `setSuccessMessage(string)` | Message shown after a successful payment | `Payment received!` | -| `setInvoiceMemo(string)` | Memo attached to the invoice | `''` | -| `addBackendsFile(string $path)` / `addBackend(string $username, ...)` | Register invoice backends | — | - -## Adding a new backend - -Backends are keyed by a `type` string, resolved through the `PhpLightning\Config\Backend\BackendType` enum. To add one: - -- Add a case to `PhpLightning\Config\Backend\BackendType`. -- Handle that case in `LightningConfig::createBackendConfig()`. -- Implement `PhpLightning\Invoice\Domain\BackendInvoice\BackendInvoiceInterface`. - -## Development / Testing - -```bash -composer test-all # quality + phpunit + rector (dry-run) -``` - -Other useful scripts: - -- `composer test-phpunit` — run the PHPUnit suite -- `composer quality` — php-cs-fixer (dry-run), psalm, phpstan -- `composer fix` — php-cs-fixer + rector (apply fixes) - -See [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) before opening a PR. - -## Wiki +## Contributing -More details in the [wiki](https://github.com/php-lightning/lnaddress/wiki). +Issues and pull requests are welcome — read +[CONTRIBUTING](.github/CONTRIBUTING.md) and the +[Code of Conduct](.github/CODE_OF_CONDUCT.md), and run `composer test-all` first. -## Contributions +## License -Issues and pull requests are welcome. Licensed under [MIT](LICENSE). +MIT — see [LICENSE](LICENSE). diff --git a/composer.json b/composer.json index 19c2e32..6fffc06 100644 --- a/composer.json +++ b/composer.json @@ -3,23 +3,23 @@ "type": "library", "license": "MIT", "require": { - "php": ">=8.2", - "gacela-project/gacela": "^1.19", + "php": ">=8.3", + "gacela-project/gacela": "^1.21", "gacela-project/router": "^0.13", - "symfony/http-client": "^7.2" + "symfony/http-client": "^7.4" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.75", + "friendsofphp/php-cs-fixer": "^3.95", "gacela-project/phpstan-extension": "^0.4", "phpstan/phpstan": "^2.2", - "phpunit/phpunit": "^10.5", + "phpunit/phpunit": "^12.5", "psalm/plugin-phpunit": "^0.19", - "rector/rector": "^2.0", - "vimeo/psalm": "^6.11" + "rector/rector": "^2.5", + "vimeo/psalm": "^6.16" }, "config": { "platform": { - "php": "8.2.27" + "php": "8.3.16" }, "allow-plugins": { "composer/package-versions-deprecated": true diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..0027ed6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,33 @@ +# Documentation + +`php-lightning/lnaddress` turns a domain you control into a +[Lightning Address](https://lightningaddress.com/) provider: it implements +[LNURL-pay (LUD-06)](https://github.com/lnurl/luds/blob/luds/06.md) and issues invoices +through a pluggable backend ([LNbits](https://lnbits.com/) today). + +| Guide | What's inside | +|---|---| +| [Getting started](getting-started.md) | Install, configure, run locally, deploy | +| [Configuration](configuration.md) | Every `LightningConfig` setter, `backends.json`, defaults | +| [HTTP API](api.md) | Routes, payloads, CORS, error objects | +| [Architecture](architecture.md) | Gacela modules, layers, request flow, adding a backend | +| [Development](development.md) | Scripts, tests, static analysis, releasing | + +## In one minute + +```bash +composer install # copies backends.dist.json → backends.json +cp lightning-config.dist.php lightning-config.php # domain, receiver, callback URL +composer serve # http://localhost:8080 +``` + +```bash +curl 'http://localhost:8080/bob' # LNURL-pay params +curl 'http://localhost:8080/bob?amount=2000' # bolt11 invoice for 2000 msat +``` + +## Requirements + +- PHP >= 8.3 +- An LNbits instance (or compatible endpoint) with an invoice/read API key +- HTTPS on a public domain for real-world use diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..319234d --- /dev/null +++ b/docs/api.md @@ -0,0 +1,114 @@ +# HTTP API + +A single route serves the whole [LNURL-pay flow](https://github.com/lnurl/luds/blob/luds/06.md): +`GET|OPTIONS /{username?}`. The `amount` query parameter decides which step you get. + +| Request | Step | Response | +|---|---|---| +| `GET /{username}` | LNURL-pay request | pay params (`tag: payRequest`) | +| `GET /{username}?amount={msat}` | LNURL-pay callback | bolt11 invoice under `pr` | +| `OPTIONS /{username}` | CORS preflight | empty body, CORS headers | + +`username` is optional — without it the configured `receiver` is used. `amount` is in +**millisatoshis**; the backend is billed in sats (`amount / 1000`). + +Every response carries `Access-Control-Allow-Origin: *` (`CorsMiddleware`), so +browser-based wallets can call the endpoint directly. + +## 1. Pay params + +```bash +curl 'https://example.com/bob' +``` + +```json +{ + "callback": "https://example.com", + "maxSendable": 10000000000, + "minSendable": 100000, + "metadata": "[[\"text/plain\",\"Pay to bob@example.com\"],[\"text/identifier\",\"bob@example.com\"]]", + "tag": "payRequest", + "commentAllowed": false +} +``` + +- `callback` is `setCallbackUrl()` verbatim — wallets append `?amount=`. +- `metadata` is built by `LnurlPayMetadata` with `json_encode`, so quotes in your + description or address cannot break the JSON. +- `commentAllowed` is always `false` (LUD-12 comments are not implemented). + +## 2. Invoice + +```bash +curl 'https://example.com/bob?amount=2000' +``` + +```json +{ + "pr": "lnbc20n1p...", + "status": "OK", + "memo": "", + "successAction": { "tag": "message", "message": "Payment received!" }, + "routes": [], + "disposable": false, + "error": null +} +``` + +The bolt11 invoice is under `pr`, as LUD-06 requires. The backend receives the amount in +sats plus `description_hash` (sha256 of the metadata) and `unhashed_description`. + +## Errors + +Errors come back as the LNURL error object. Uncaught exceptions are converted by +`InvoiceExceptionHandler`, registered globally in `InvoiceRoutesPlugin`, so no controller +needs a try/catch. + +```json +{ "status": "ERROR", "reason": "Amount is not between minimum and maximum sendable amount" } +``` + +```json +{ "status": "ERROR", "reason": "Missing backend options for carol" } +``` + +An unreachable backend answers with `status: ERROR` and +`error: Backend "LnBits" unreachable`. + +## Serving a real Lightning Address + +Wallets resolve `bob@example.com` through: + +``` +https://example.com/.well-known/lnurlp/bob +``` + +Route that path to `/{username}`. With nginx: + +```nginx +location /.well-known/lnurlp/ { + rewrite ^/\.well-known/lnurlp/(.*)$ /$1 last; +} + +location / { + try_files $uri /index.php$is_args$args; +} +``` + +Requirements for interoperability: HTTPS, JSON responses, and a `callback` URL reachable +from the public internet. + +## Calling it without HTTP + +The facade is usable directly, which is what the feature tests do: + +```php +use Gacela\Framework\Gacela; +use PhpLightning\Invoice\InvoiceFacade; + +Gacela::bootstrap(__DIR__); + +$facade = new InvoiceFacade(); +$payParams = $facade->getCallbackUrl('bob'); +$invoice = $facade->generateInvoice('bob', 2_000); // millisats +``` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..1d71d4b --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,94 @@ +# Architecture + +A small [Gacela](https://gacela-project.com) modular monolith: every module exposes a +**Facade**, builds its objects in a **Factory**, reads settings through a **Config**, and +declares external dependencies in a **Provider**. Nothing outside a module touches its +internals. + +![Lightning Address workflow](../images/lnaddr_workflow.png) + +## Request flow + +``` +public/index.php + └─ Gacela::bootstrap() gacela.php: app config, router, plugins + └─ Router::run() + └─ InvoiceRoutesPlugin GET|OPTIONS {username?} + ├─ CorsMiddleware CORS headers; short-circuits OPTIONS + ├─ InvoiceExceptionHandler any Throwable → LNURL error object + └─ InvoiceController reads ?amount + └─ InvoiceFacade + ├─ getCallbackUrl($username) amount == 0 + │ └─ Application\CallbackUrl → pay params + └─ generateInvoice($username, $msat) + └─ Application\InvoiceGenerator + └─ Domain\BackendInvoice\LnbitsBackendInvoice + └─ Infrastructure\Http\HttpApi (Symfony HttpClient) +``` + +## Module layout + +``` +src/ +├── Invoice/ the feature module +│ ├── InvoiceFacade.php public API @extends AbstractFacade +│ ├── InvoiceFactory.php wiring @extends AbstractFactory +│ ├── InvoiceConfig.php typed config reads + per-user backend lookup +│ ├── InvoiceDependencyProvider.php external deps (HTTP_API) +│ ├── Application/ CallbackUrl, InvoiceGenerator +│ ├── Domain/ BackendInvoice, CallbackUrl, Http — interfaces + logic +│ └── Infrastructure/ Controller, Handler, Middleware, Http, Plugin +├── Config/ LightningConfig, BackendsConfig, BackendType, LnBitsBackendConfig +└── Shared/ Config\ConfigKey, Transfer\*, Value\SendableRange, Value\LnurlPayMetadata +``` + +**Layer rules** + +- `Application` orchestrates and depends on `Domain` interfaces only. +- `Domain` is pure: no HTTP, no framework, no config lookups — collaborators arrive as + constructor arguments (`HttpApiInterface`, `BackendInvoiceInterface`, + `LnAddressGeneratorInterface`). +- `Infrastructure` holds everything that talks to the outside world. +- `Shared` carries data across modules: `Transfer` suffix for DTOs, value objects for + invariants (`SendableRange`, `LnurlPayMetadata`). + +**Gacela specifics** + +- The abstract classes are templated: annotate with `@extends AbstractFacade` + and `@extends AbstractFactory`. +- `InvoiceController` resolves its facade through `ServiceResolverAwareTrait` plus the + `@method InvoiceFacade getFacade()` docblock (`DocBlockResolverAwareTrait` is deprecated). +- `InvoiceDependencyProvider` registers `HTTP_API`; that seam is how the Symfony-backed + `HttpApi` reaches the domain, and how feature tests swap in `FakeHttpApi`. + +## Key objects + +| Class | Responsibility | +|---|---| +| `Application\CallbackUrl` | Builds the LUD-06 pay params (`callback`, min/max sendable, metadata) | +| `Application\InvoiceGenerator` | Validates the amount, converts msat → sat, maps `InvoiceTransfer` to the response | +| `Domain\CallbackUrl\LnAddressGenerator` | Resolves `username@domain`, falling back to the configured receiver | +| `Domain\BackendInvoice\LnbitsBackendInvoice` | `POST {api_endpoint}/api/v1/payments` with `X-Api-Key`, `description_hash`, `unhashed_description` | +| `Domain\BackendInvoice\EmptyBackendInvoice` | Null object for users without a usable backend | +| `Infrastructure\Middleware\CorsMiddleware` | `Access-Control-Allow-Origin: *`; answers `OPTIONS` preflight | +| `Infrastructure\Handler\InvoiceExceptionHandler` | Turns any `Throwable` into `{status: ERROR, reason: …}` | +| `Shared\Value\SendableRange` | Min/max msat bounds and `contains()` | +| `Shared\Value\LnurlPayMetadata` | LUD-06 metadata, JSON-encoded so quotes stay safe | +| `Shared\Config\ConfigKey` | The config key strings, shared by writer and reader | + +## Adding a backend + +`LnbitsBackendInvoice` is one implementation of `BackendInvoiceInterface`: + +1. Add a case to `PhpLightning\Config\Backend\BackendType`. +2. Handle it in `LightningConfig::createBackendConfig()` with a + `BackendConfigInterface` implementation. +3. Implement `BackendInvoiceInterface` under `Invoice/Domain/BackendInvoice/`. +4. Select it in `InvoiceFactory::getBackendForUser()`. + +The Application layer stays untouched — it only knows the interface. + +## Not implemented + +Marked as TODO in the code: LNURL comments (`commentAllowed` is always `false`) and image +metadata in the pay params. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..a576418 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,101 @@ +# Configuration + +Two files drive the app, both read at bootstrap through `gacela.php`: + +| File | Purpose | In git | +|---|---|---| +| `lightning-config.dist.php` | Shipped defaults | yes | +| `lightning-config.php` | Your overrides — wins over the dist file | no | +| `backends.dist.json` | Example backends | yes | +| `backends.json` | Your users + API keys | no | + +## `LightningConfig` + +`lightning-config.php` returns a `PhpLightning\Config\LightningConfig`. Only the values you +set are serialized, so anything you leave out keeps its default. + +```php +use PhpLightning\Config\LightningConfig; + +return (new LightningConfig()) + ->setDomain('example.com') + ->setReceiver('bob') + ->setDescriptionTemplate('Pay to %s') + ->setSuccessMessage('Payment received!') + ->setInvoiceMemo('coffee fund') + ->setSendableRange(min: 100_000, max: 10_000_000_000) + ->setCallbackUrl('https://example.com') + ->addBackendsFile(getcwd() . DIRECTORY_SEPARATOR . 'backends.json'); +``` + +| Setter | Config key | Default | Notes | +|---|---|---|---| +| `setDomain(string)` | `domain` | `$_SERVER['HTTP_HOST']`, else `localhost` | A full URL is accepted; only the host is kept | +| `setReceiver(string)` | `receiver` | `unknown-receiver` | Username used when the URL has none | +| `setCallbackUrl(string)` | `callback-url` | `undefined:callback-url` | Returned as `callback`; wallets must be able to reach it | +| `setSendableRange(int $min, int $max)` | `sendable-range` | `100_000` – `10_000_000_000` msat | Amounts outside the range are rejected | +| `setDescriptionTemplate(string)` | `description-template` | `Pay to %s` | `%s` is the Lightning Address; shown in the payer's wallet | +| `setSuccessMessage(string)` | `success-message` | `Payment received!` | Returned as `successAction.message` | +| `setInvoiceMemo(string)` | `invoice-memo` | `''` | Memo stored on the invoice at the backend | +| `addBackendsFile(string $path)` | `backends` | — | Throws if the file does not exist; callable more than once | +| `addBackend(string $username, BackendConfigInterface)` | `backends` | — | Register a backend without a JSON file | + +Amounts are always **millisatoshis** (msat); the default range is 100 sat to +10 000 000 sat. The key strings live in `PhpLightning\Shared\Config\ConfigKey`, shared by +the writer (`LightningConfig`) and the reader (`InvoiceConfig`) so they cannot drift. + +## Backends file (`backends.json`) + +A map of username to backend settings. The username is the local part of the Lightning +Address (`bob@example.com` -> `bob`) and the path segment of the request (`/bob`). + +```json +{ + "bob": { + "type": "lnbits", + "api_key": "abc...123", + "api_endpoint": "https://legend.lnbits.com" + }, + "alice": { + "type": "lnbits", + "api_key": "def...456", + "api_endpoint": "http://localhost:5000" + } +} +``` + +- `type` is resolved through the `BackendType` enum; an unknown value fails fast with + `Unknown backend type "x". Supported types: lnbits`. +- `api_endpoint` loses any trailing slash; the client calls + `{api_endpoint}/api/v1/payments`. +- `api_key` is an LNbits **invoice/read** key — it only needs invoice-creation rights. +- Requesting a username that is not listed fails with + `Missing backend options for `. + +Treat `backends.json` as a secret; it is gitignored on purpose. + +### Without a JSON file + +```php +use PhpLightning\Config\Backend\LnBitsBackendConfig; + +$config->addBackend('bob', LnBitsBackendConfig::withEndpointAndKey('http://localhost:5000', 'abc...123')); +``` + +## Gacela bootstrap (`gacela.php`) + +```php +return static function (GacelaConfig $config): void { + $config + ->enableFileCache() + ->addAppConfig('lightning-config.dist.php', 'lightning-config.php') + ->extendGacelaConfig(RouterGacelaConfig::class) + ->addPlugin(InvoiceRoutesPlugin::class); +}; +``` + +- `addAppConfig(dist, override)` is what makes `lightning-config.php` optional. +- `enableFileCache()` caches resolved classes/config under `.gacela/`; delete that + directory if a stale value survives a config change. +- `InvoiceRoutesPlugin` registers the route, the CORS middleware and the exception + handler. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..527661e --- /dev/null +++ b/docs/development.md @@ -0,0 +1,96 @@ +# Development + +```bash +composer install +composer test-all +``` + +## Composer scripts + +| Script | What it runs | +|---|---| +| `composer test-all` | `quality` + PHPUnit + `rector --dry-run` — the CI gate | +| `composer quality` | php-cs-fixer (dry-run), Psalm, PHPStan | +| `composer phpunit` | PHPUnit only | +| `composer test-coverage` | PHPUnit with HTML coverage in `data/coverage-html` | +| `composer fix` | php-cs-fixer + Rector, writing changes | +| `composer ctal` | clear static caches, `fix`, then `test-all` | +| `composer static-clear-cache` | Clear the Psalm and PHPStan caches | +| `composer serve` | `php -S localhost:8080 public/index.php` | + +Static analysis runs with `XDEBUG_MODE=off`; coverage flips it to `coverage`. + +## Tests + +- `tests/Unit/` mirrors `src/` and needs no bootstrap — plain PHPUnit with stubs/mocks. +- `tests/Feature/` boots Gacela end to end. `tests/Feature/backends.json` supplies the + fixture users and `tests/Feature/Fake/FakeHttpApi` replaces the HTTP client, so no + network call ever happens. + +A feature test bootstraps Gacela with in-line config, then overrides the module's provider +to inject the fake: + +```php +Gacela::bootstrap(__DIR__, static function (GacelaConfig $config): void { + $config->resetInMemoryCache(); + $config->addAppConfigKeyValues( + (new LightningConfig()) + ->setCallbackUrl('https://callback.url/receiver') + ->setDomain('domain.com') + ->setReceiver('receiver') + ->setSendableRange(1_000, 10_000) + ->addBackendsFile(__DIR__ . DIRECTORY_SEPARATOR . 'backends.json') + ->jsonSerialize(), + ); +}); + +Gacela::overrideExistingResolvedClass( + InvoiceDependencyProvider::class, + new class() extends AbstractProvider { + public function provideModuleDependencies(Container $container): void + { + $container->set(InvoiceDependencyProvider::HTTP_API, static fn () => new FakeHttpApi()); + } + }, +); +``` + +PHPUnit 12 is in use: attributes instead of annotations, `createStub()` / `createMock()`, +no `withConsecutive()`. + +## Static analysis + +- **Psalm** at `errorLevel="1"`, target PHP 8.3, PHPUnit plugin enabled. +- **PHPStan** at `level: max` with the gacela extension (`modulesNamespace: PhpLightning`). +- **Rector** with the PHP 8.3 set plus dead-code and code-quality sets, over `src/` and + `tests/`. +- **php-cs-fixer** with `.php-cs-fixer.dist.php`. + +Fix findings at the source instead of widening the ignore lists. If PHPStan dies with an +out-of-memory error inside `resultCache.php`, the cause is a stale result cache — run +`vendor/bin/phpstan clear-result-cache`, not a bigger memory limit. + +## Conventions + +- `declare(strict_types=1);` everywhere; classes `final`, `readonly` when stateless. +- Typed class constants (`public const string FOO = '…';`). +- Domain code depends on interfaces; wiring lives in the Factory / DependencyProvider. +- DTOs live in `src/Shared/Transfer/` with the `Transfer` suffix. +- [Conventional commits](https://www.conventionalcommits.org/); `ref:` for refactors. + +## CI + +`.github/workflows/ci.yml` runs on every push and pull request: + +1. **Coding Guidelines** — php-cs-fixer dry-run +2. **Type Checker** — Psalm (with Shepherd) and PHPStan +3. **Tests** — PHPUnit on PHP 8.3, 8.4 and 8.5, with locked and highest dependencies + +Scrutinizer (`.scrutinizer.yml`) collects coverage separately. + +## Releasing + +1. Move the `Unreleased` entries in [`CHANGELOG.md`](../CHANGELOG.md) under the new + version and date, and refresh the compare links. +2. Tag `main` (`0.10.0` — tags are unprefixed since 0.2.0). +3. Push the tag and publish the GitHub release with that changelog section as the notes. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..2e7105f --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,99 @@ +# Getting started + +## Requirements + +- PHP >= 8.3 +- Composer 2 +- An LNbits wallet (self-hosted or hosted) — you need its API endpoint and API key + +## Install + +### Standalone + +```bash +git clone https://github.com/php-lightning/lnaddress.git +cd lnaddress +composer install +``` + +The `post-install-cmd` script copies `backends.dist.json` to `backends.json` when the +latter does not exist yet. + +### As a dependency + +```bash +composer require php-lightning/lnaddress +``` + +The [demo template](https://github.com/php-lightning/demo-lnaddress) is a ready-made +project built on top of this library: it keeps your config and pulls features and fixes +with `composer update`. + +## Configure + +Two files, both gitignored so your keys never reach version control: + +```bash +cp lightning-config.dist.php lightning-config.php +cp backends.dist.json backends.json # already done by composer install +``` + +**`backends.json`** — one entry per username you serve: + +```json +{ + "bob": { + "type": "lnbits", + "api_key": "abc...123", + "api_endpoint": "https://legend.lnbits.com" + } +} +``` + +**`lightning-config.php`** — everything else: + +```php +setDomain('example.com') + ->setReceiver('bob') + ->setDescriptionTemplate('Pay to %s') + ->setSuccessMessage('Thanks for the payment!') + ->setInvoiceMemo('') + ->setSendableRange(min: 100_000, max: 10_000_000_000) + ->setCallbackUrl('https://example.com') + ->addBackendsFile(getcwd() . DIRECTORY_SEPARATOR . 'backends.json'); +``` + +`lightning-config.php` overrides `lightning-config.dist.php` key by key. Full reference in +[Configuration](configuration.md). + +## Run locally + +```bash +composer serve # php -S localhost:8080 public/index.php +``` + +```bash +curl 'http://localhost:8080/bob' +curl 'http://localhost:8080/bob?amount=2000' +``` + +> The `callback` URL is what paying wallets call in step 2 of the LNURL-pay flow, so it +> must match the host you actually serve — `http://localhost:8080` while testing locally. + +## Go live + +Wallets resolve `bob@example.com` by fetching: + +``` +https://example.com/.well-known/lnurlp/bob +``` + +Serve `public/index.php` on that domain over HTTPS and rewrite +`/.well-known/lnurlp/{username}` to `/{username}`. Keep `setDomain()` and +`setCallbackUrl()` in sync with the public URL. Details and an nginx snippet in +[HTTP API](api.md). diff --git a/phpunit.xml b/phpunit.xml index fc6c350..e1912e0 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,16 @@ - + diff --git a/psalm.xml b/psalm.xml index 07d925b..51040c1 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,7 +1,7 @@ withPhpSets(php82: true) + ->withPhpSets(php83: true) ->withPreparedSets( deadCode: true, codeQuality: true, diff --git a/src/Invoice/Application/CallbackUrl.php b/src/Invoice/Application/CallbackUrl.php index 5fcde3c..3c8dc69 100644 --- a/src/Invoice/Application/CallbackUrl.php +++ b/src/Invoice/Application/CallbackUrl.php @@ -11,7 +11,7 @@ final readonly class CallbackUrl implements CallbackUrlInterface { - private const TAG_PAY_REQUEST = 'payRequest'; + private const string TAG_PAY_REQUEST = 'payRequest'; public function __construct( private SendableRange $sendableRange, diff --git a/src/Invoice/InvoiceDependencyProvider.php b/src/Invoice/InvoiceDependencyProvider.php index 31f7fed..28c502b 100644 --- a/src/Invoice/InvoiceDependencyProvider.php +++ b/src/Invoice/InvoiceDependencyProvider.php @@ -13,7 +13,7 @@ */ final class InvoiceDependencyProvider extends AbstractProvider { - public const HTTP_API = 'HTTP_API'; + public const string HTTP_API = 'HTTP_API'; public function provideModuleDependencies(Container $container): void { diff --git a/src/Shared/Config/ConfigKey.php b/src/Shared/Config/ConfigKey.php index 2e8f4e0..70902de 100644 --- a/src/Shared/Config/ConfigKey.php +++ b/src/Shared/Config/ConfigKey.php @@ -11,12 +11,12 @@ */ final class ConfigKey { - public const BACKENDS = 'backends'; - public const DOMAIN = 'domain'; - public const RECEIVER = 'receiver'; - public const SENDABLE_RANGE = 'sendable-range'; - public const CALLBACK_URL = 'callback-url'; - public const DESCRIPTION_TEMPLATE = 'description-template'; - public const SUCCESS_MESSAGE = 'success-message'; - public const INVOICE_MEMO = 'invoice-memo'; + public const string BACKENDS = 'backends'; + public const string DOMAIN = 'domain'; + public const string RECEIVER = 'receiver'; + public const string SENDABLE_RANGE = 'sendable-range'; + public const string CALLBACK_URL = 'callback-url'; + public const string DESCRIPTION_TEMPLATE = 'description-template'; + public const string SUCCESS_MESSAGE = 'success-message'; + public const string INVOICE_MEMO = 'invoice-memo'; } diff --git a/src/Shared/Value/SendableRange.php b/src/Shared/Value/SendableRange.php index 1c3fc09..e235420 100644 --- a/src/Shared/Value/SendableRange.php +++ b/src/Shared/Value/SendableRange.php @@ -7,10 +7,10 @@ final readonly class SendableRange { /** @var int 100 Minimum in msat (sat/1000) */ - private const DEFAULT_MIN_SENDABLE_IN_MILLISATS = 100_000; + private const int DEFAULT_MIN_SENDABLE_IN_MILLISATS = 100_000; /** @var int 10 000 000 Maximum in msat (sat/1000) */ - private const DEFAULT_MAX_SENDABLE_IN_MILLISATS = 10_000_000_000; + private const int DEFAULT_MAX_SENDABLE_IN_MILLISATS = 10_000_000_000; private function __construct( private int $min,