diff --git a/.gitignore b/.gitignore
index 5e174c9..6071ba1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,4 +10,6 @@ var/
*.cache
composer.lock
lightning-config.php
-nostr.json
+backends.json
+.phpunit.cache
+.phpunit.result.cache
diff --git a/README.md b/README.md
index 761776a..0e78877 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,5 @@
# PHP Lightning Address
-PHP Lightning Address is an easy way to get a [lightning address](https://lightningaddress.com/) in PHP.
-
@@ -20,43 +18,182 @@ PHP Lightning Address is an easy way to get a [lightning address](https://lightn
-## Usage / Development
+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
-Set up your custom config:
+- PHP >= 8.2
+
+## Install
+
+```bash
+composer require php-lightning/lnaddress
+```
+
+`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
-# or just simply the nostr.json to define the backends/user-settings
-cp nostr.dist.json nostr.json
```
-You can customize the invoice description and the success message by editing
-`lightning-config.php`:
+`LightningConfig` has a fluent API:
```php
use PhpLightning\Config\LightningConfig;
return (new LightningConfig())
- ->setDescriptionTemplate('Pay to %s on mynode')
- ->setSuccessMessage('Thanks for the payment!');
+ ->setDomain('yourdomain.com')
+ ->setReceiver('default-receiver')
+ ->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');
```
-Run a local PHP server listening `public/index.php`
+### 2. Backends — `backends.json`
+
+```bash
+cp backends.dist.json backends.json
+```
+
+Each username maps to its own invoice backend:
+
+```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)
+
+You can skip `backends.json` and register backends directly in `lightning-config.php`:
+
+```php
+use PhpLightning\Config\Backend\LnBitsBackendConfig;
+
+$config->addBackend('bob', LnBitsBackendConfig::withEndpointAndKey('http://localhost:5000', 'abc...123'));
+```
+
+## Run the server
```bash
composer serve
```
-### Demo template
+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:
-We prepared a demo template, so you can use this project as a dependency. The benefits from this approach is that you can update your project with `composer update` whenever there are new features or improvements on this `lnaddress` repository.
+- `composer test-phpunit` — run the PHPUnit suite
+- `composer quality` — php-cs-fixer (dry-run), psalm, phpstan
+- `composer fix` — php-cs-fixer + rector (apply fixes)
-> [https://github.com/php-lightning/demo-lnaddress](https://github.com/php-lightning/demo-lnaddress)
+See [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) before opening a PR.
## Wiki
-Check the wiki for more details: [https://github.com/php-lightning/lnaddress/wiki](https://github.com/php-lightning/lnaddress/wiki)
+More details in the [wiki](https://github.com/php-lightning/lnaddress/wiki).
## Contributions
-Feel free to open issues & PR if you want to contribute to this project.
+Issues and pull requests are welcome. Licensed under [MIT](LICENSE).
diff --git a/nostr.dist.json b/backends.dist.json
similarity index 100%
rename from nostr.dist.json
rename to backends.dist.json
diff --git a/composer.json b/composer.json
index 6db052e..19c2e32 100644
--- a/composer.json
+++ b/composer.json
@@ -4,18 +4,17 @@
"license": "MIT",
"require": {
"php": ">=8.2",
- "gacela-project/gacela": "^1.9",
- "gacela-project/router": "^0.12",
+ "gacela-project/gacela": "^1.19",
+ "gacela-project/router": "^0.13",
"symfony/http-client": "^7.2"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.75",
- "gacela-project/phpstan-extension": "^0.3",
- "phpstan/phpstan": "^1.12",
- "phpunit/phpunit": "^9.6",
+ "gacela-project/phpstan-extension": "^0.4",
+ "phpstan/phpstan": "^2.2",
+ "phpunit/phpunit": "^10.5",
"psalm/plugin-phpunit": "^0.19",
- "rector/rector": "^1.2",
- "symfony/var-dumper": "^7.2",
+ "rector/rector": "^2.0",
"vimeo/psalm": "^6.11"
},
"config": {
@@ -41,7 +40,7 @@
},
"scripts": {
"post-install-cmd": [
- "[ ! -f nostr.json ] && cp nostr.dist.json nostr.json || true"
+ "[ ! -f backends.json ] && cp backends.dist.json backends.json || true"
],
"ctal": [
"@static-clear-cache",
diff --git a/lightning-config.dist.php b/lightning-config.dist.php
index 1ce8000..8846a4e 100644
--- a/lightning-config.dist.php
+++ b/lightning-config.dist.php
@@ -7,9 +7,12 @@
return (new LightningConfig())
->setDomain('localhost')
->setReceiver('default-receiver')
+ // %s is replaced with the payer-facing lightning address
->setDescriptionTemplate('Pay to %s')
->setSuccessMessage('Payment received!')
->setInvoiceMemo('')
+ // min/max are in millisats (sat * 1000)
->setSendableRange(min: 100_000, max: 10_000_000_000)
- ->setCallbackUrl('localhost:8000/callback')
- ->addBackendsFile(getcwd() . DIRECTORY_SEPARATOR . 'nostr.json');
+ // public URL wallets call back to request the invoice
+ ->setCallbackUrl('http://localhost:8080')
+ ->addBackendsFile(getcwd() . DIRECTORY_SEPARATOR . 'backends.json');
diff --git a/phpunit.xml b/phpunit.xml
index cd6ac7e..fc6c350 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,34 +1,20 @@
-
-
-
-
-
-
-
-
- tests/Unit
-
-
- tests/Feature
-
-
-
-
-
- src
-
-
+
+
+
+
+
+
+
+ tests/Unit
+
+
+ tests/Feature
+
+
+
+
+ src
+
+
diff --git a/src/Config/Backend/BackendType.php b/src/Config/Backend/BackendType.php
new file mode 100644
index 0000000..512896f
--- /dev/null
+++ b/src/Config/Backend/BackendType.php
@@ -0,0 +1,26 @@
+ $t->value, self::cases())),
+ ));
+ }
+}
diff --git a/src/Config/LightningConfig.php b/src/Config/LightningConfig.php
index 7b50481..2a4d112 100644
--- a/src/Config/LightningConfig.php
+++ b/src/Config/LightningConfig.php
@@ -5,10 +5,16 @@
namespace PhpLightning\Config;
use JsonSerializable;
+use PhpLightning\Config\Backend\BackendConfigInterface;
+use PhpLightning\Config\Backend\BackendType;
use PhpLightning\Config\Backend\LnBitsBackendConfig;
+use PhpLightning\Shared\Config\ConfigKey;
use PhpLightning\Shared\Value\SendableRange;
use RuntimeException;
+use function is_array;
+use function sprintf;
+
final class LightningConfig implements JsonSerializable
{
private ?BackendsConfig $backends = null;
@@ -63,33 +69,29 @@ public function setInvoiceMemo(string $memo): self
return $this;
}
- public function addBackendsFile(string $path): self
+ public function addBackend(string $username, BackendConfigInterface $backendConfig): self
{
$this->backends ??= new BackendsConfig();
+ $this->backends->add($username, $backendConfig);
+ return $this;
+ }
+
+ public function addBackendsFile(string $path): self
+ {
+ if (!is_file($path)) {
+ throw new RuntimeException(sprintf('Backends file not found: "%s"', $path));
+ }
- $jsonAsString = (string)file_get_contents($path);
- /** @var array $json
- */
- $json = json_decode($jsonAsString, true);
-
- foreach ($json as $user => $settings) {
- if (!isset($settings['type'])) {
- throw new RuntimeException('"type" missing');
- }
-
- if ($settings['type'] === 'lnbits') { // TODO: refactor
- $this->backends->add(
- $user,
- LnBitsBackendConfig::withEndpointAndKey(
- $settings['api_endpoint'] ?? '',
- $settings['api_key'] ?? '',
- ),
- );
- }
+ /** @var mixed $json */
+ $json = json_decode((string)file_get_contents($path), true, flags: JSON_THROW_ON_ERROR);
+ if (!is_array($json)) {
+ throw new RuntimeException(sprintf('Backends file "%s" must contain a JSON object', $path));
+ }
+
+ /** @var array $json */
+ foreach ($json as $username => $settings) {
+ // A numeric username in JSON arrives as an int array key; cast so strict_types holds.
+ $this->addBackend((string)$username, $this->createBackendConfig((string)$username, $settings));
}
return $this;
@@ -99,30 +101,47 @@ public function jsonSerialize(): array
{
$result = [];
if ($this->backends instanceof BackendsConfig) {
- $result['backends'] = $this->backends->jsonSerialize();
+ $result[ConfigKey::BACKENDS] = $this->backends->jsonSerialize();
}
if ($this->domain !== null) {
- $result['domain'] = $this->domain;
+ $result[ConfigKey::DOMAIN] = $this->domain;
}
if ($this->receiver !== null) {
- $result['receiver'] = $this->receiver;
+ $result[ConfigKey::RECEIVER] = $this->receiver;
}
if ($this->sendableRange instanceof SendableRange) {
- $result['sendable-range'] = $this->sendableRange;
+ $result[ConfigKey::SENDABLE_RANGE] = $this->sendableRange;
}
if ($this->callbackUrl !== null) {
- $result['callback-url'] = $this->callbackUrl;
+ $result[ConfigKey::CALLBACK_URL] = $this->callbackUrl;
}
if ($this->descriptionTemplate !== null) {
- $result['description-template'] = $this->descriptionTemplate;
+ $result[ConfigKey::DESCRIPTION_TEMPLATE] = $this->descriptionTemplate;
}
if ($this->successMessage !== null) {
- $result['success-message'] = $this->successMessage;
+ $result[ConfigKey::SUCCESS_MESSAGE] = $this->successMessage;
}
if ($this->invoiceMemo !== null) {
- $result['invoice-memo'] = $this->invoiceMemo;
+ $result[ConfigKey::INVOICE_MEMO] = $this->invoiceMemo;
}
return $result;
}
+
+ /**
+ * @param array{type?: string, api_endpoint?: string, api_key?: string} $settings
+ */
+ private function createBackendConfig(string $username, array $settings): BackendConfigInterface
+ {
+ if (!isset($settings['type'])) {
+ throw new RuntimeException(sprintf('Missing "type" for backend "%s"', $username));
+ }
+
+ return match (BackendType::fromString($settings['type'])) {
+ BackendType::Lnbits => LnBitsBackendConfig::withEndpointAndKey(
+ $settings['api_endpoint'] ?? '',
+ $settings['api_key'] ?? '',
+ ),
+ };
+ }
}
diff --git a/src/Invoice/Application/CallbackUrl.php b/src/Invoice/Application/CallbackUrl.php
index 6adcfc2..5fcde3c 100644
--- a/src/Invoice/Application/CallbackUrl.php
+++ b/src/Invoice/Application/CallbackUrl.php
@@ -6,10 +6,9 @@
use PhpLightning\Invoice\Domain\CallbackUrl\CallbackUrlInterface;
use PhpLightning\Invoice\Domain\CallbackUrl\LnAddressGeneratorInterface;
+use PhpLightning\Shared\Value\LnurlPayMetadata;
use PhpLightning\Shared\Value\SendableRange;
-use function sprintf;
-
final readonly class CallbackUrl implements CallbackUrlInterface
{
private const TAG_PAY_REQUEST = 'payRequest';
@@ -25,21 +24,14 @@ public function __construct(
public function getCallbackUrl(string $username): array
{
$lnAddress = $this->lnAddressGenerator->generate($username);
- // Modify the description if you want to custom it
- // This will be the description on the wallet that pays your ln address
- // TODO: Make this customizable from some external configuration file
- $description = sprintf($this->descriptionTemplate, $lnAddress);
-
- // TODO: images not implemented yet; `',["image/jpeg;base64","' . base64_encode($response) . '"]';`
- $imageMetadata = '';
- $metadata = '[["text/plain","' . $description . '"],["text/identifier","' . $lnAddress . '"]' . $imageMetadata . ']';
+ $metadata = new LnurlPayMetadata($this->descriptionTemplate, $lnAddress);
- // payRequest json data, spec : https://github.com/lnurl/luds/blob/luds/06.md
+ // payRequest json data, spec: https://github.com/lnurl/luds/blob/luds/06.md
return [
'callback' => $this->callback,
'maxSendable' => $this->sendableRange->max(),
'minSendable' => $this->sendableRange->min(),
- 'metadata' => $metadata,
+ 'metadata' => (string)$metadata,
'tag' => self::TAG_PAY_REQUEST,
'commentAllowed' => false, // TODO: Not implemented yet
];
diff --git a/src/Invoice/Application/InvoiceGenerator.php b/src/Invoice/Application/InvoiceGenerator.php
index 4c3dbd1..f3afeed 100644
--- a/src/Invoice/Application/InvoiceGenerator.php
+++ b/src/Invoice/Application/InvoiceGenerator.php
@@ -5,12 +5,10 @@
namespace PhpLightning\Invoice\Application;
use PhpLightning\Invoice\Domain\BackendInvoice\BackendInvoiceInterface;
-
use PhpLightning\Shared\Transfer\InvoiceTransfer;
+use PhpLightning\Shared\Value\LnurlPayMetadata;
use PhpLightning\Shared\Value\SendableRange;
-use function sprintf;
-
final readonly class InvoiceGenerator
{
public function __construct(
@@ -23,6 +21,17 @@ public function __construct(
) {
}
+ /**
+ * @return array{
+ * pr: string,
+ * status: string,
+ * memo: string,
+ * successAction: array{tag: string, message: string},
+ * routes: list,
+ * disposable: bool,
+ * error: string|null,
+ * }|array{status: string, reason: string}
+ */
public function generateInvoice(int $milliSats): array
{
if (!$this->sendableRange->contains($milliSats)) {
@@ -31,21 +40,30 @@ public function generateInvoice(int $milliSats): array
'reason' => 'Amount is not between minimum and maximum sendable amount',
];
}
- $description = sprintf($this->descriptionTemplate, $this->lnAddress);
- // TODO: images not implemented yet
- $imageMetadata = '';
- $metadata = '[["text/plain","' . $description . '"],["text/identifier","' . $this->lnAddress . '"]' . $imageMetadata . ']';
+ $metadata = new LnurlPayMetadata($this->descriptionTemplate, $this->lnAddress);
- $invoice = $this->backendInvoice->requestInvoice((int)($milliSats / 1000), $metadata, $this->memo);
+ $invoice = $this->backendInvoice->requestInvoice((int)($milliSats / 1000), (string)$metadata, $this->memo);
return $this->mapResponseAsArray($invoice);
}
+ /**
+ * @return array{
+ * pr: string,
+ * status: string,
+ * memo: string,
+ * successAction: array{tag: string, message: string},
+ * routes: list,
+ * disposable: bool,
+ * error: string|null,
+ * }
+ */
private function mapResponseAsArray(InvoiceTransfer $invoice): array
{
return [
- 'bolt11' => $invoice->bolt11,
+ // LUD-06 names the invoice field "pr" (bech32 bolt11); wallets read this key.
+ 'pr' => $invoice->bolt11,
'status' => $invoice->status,
'memo' => $invoice->memo,
'successAction' => [
diff --git a/src/Invoice/Infrastructure/Controller/InvoiceController.php b/src/Invoice/Infrastructure/Controller/InvoiceController.php
index cade223..05b0811 100644
--- a/src/Invoice/Infrastructure/Controller/InvoiceController.php
+++ b/src/Invoice/Infrastructure/Controller/InvoiceController.php
@@ -4,18 +4,17 @@
namespace PhpLightning\Invoice\Infrastructure\Controller;
-use Gacela\Framework\DocBlockResolverAwareTrait;
+use Gacela\Framework\ServiceResolverAwareTrait;
use Gacela\Router\Entities\JsonResponse;
use Gacela\Router\Entities\Request;
use PhpLightning\Invoice\InvoiceFacade;
-use Throwable;
/**
* @method InvoiceFacade getFacade()
*/
final class InvoiceController
{
- use DocBlockResolverAwareTrait;
+ use ServiceResolverAwareTrait;
public function __construct(
private Request $request,
@@ -27,24 +26,17 @@ public function __construct(
*/
public function __invoke(string $username = ''): JsonResponse
{
- try {
- $amount = (int)$this->request->get('amount');
-
- if ($amount === 0) {
- return new JsonResponse(
- $this->getFacade()->getCallbackUrl($username),
- );
- }
+ // Errors bubble to InvoiceExceptionHandler (registered in InvoiceRoutesPlugin).
+ $amount = (int)$this->request->get('amount');
+ if ($amount === 0) {
return new JsonResponse(
- $this->getFacade()->generateInvoice($username, $amount),
+ $this->getFacade()->getCallbackUrl($username),
);
- } catch (Throwable $e) {
- dump($e);
- return new JsonResponse([
- 'status' => 'ERROR',
- 'message' => $e->getMessage(),
- ]);
}
+
+ return new JsonResponse(
+ $this->getFacade()->generateInvoice($username, $amount),
+ );
}
}
diff --git a/src/Invoice/Infrastructure/Handler/InvoiceExceptionHandler.php b/src/Invoice/Infrastructure/Handler/InvoiceExceptionHandler.php
new file mode 100644
index 0000000..6894fb7
--- /dev/null
+++ b/src/Invoice/Infrastructure/Handler/InvoiceExceptionHandler.php
@@ -0,0 +1,23 @@
+ 'ERROR',
+ 'reason' => $exception->getMessage(),
+ ]);
+ }
+}
diff --git a/src/Invoice/Infrastructure/Http/HttpApi.php b/src/Invoice/Infrastructure/Http/HttpApi.php
index 0540f35..69279d7 100644
--- a/src/Invoice/Infrastructure/Http/HttpApi.php
+++ b/src/Invoice/Infrastructure/Http/HttpApi.php
@@ -4,19 +4,28 @@
namespace PhpLightning\Invoice\Infrastructure\Http;
+use JsonException;
use PhpLightning\Invoice\Domain\Http\HttpApiInterface;
use Symfony\Component\HttpClient\HttpClient;
+use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
final class HttpApi implements HttpApiInterface
{
public function postRequestInvoice(string $uri, string $body, array $headers = []): ?array
{
- $response = HttpClient::create()
- ->request('POST', $uri, [
- 'headers' => $headers,
- 'body' => $body,
- ]);
+ try {
+ $response = HttpClient::create()
+ ->request('POST', $uri, [
+ 'headers' => $headers,
+ 'body' => $body,
+ ]);
- return json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
+ // getContent() throws on transport failure and on 3xx/4xx/5xx, json_decode
+ // on a malformed 200 body; null lets the backend surface a clean error
+ // instead of a raw stack trace.
+ return json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
+ } catch (ExceptionInterface|JsonException) {
+ return null;
+ }
}
}
diff --git a/src/Invoice/Infrastructure/Middleware/CorsMiddleware.php b/src/Invoice/Infrastructure/Middleware/CorsMiddleware.php
new file mode 100644
index 0000000..54b6c91
--- /dev/null
+++ b/src/Invoice/Infrastructure/Middleware/CorsMiddleware.php
@@ -0,0 +1,27 @@
+isMethod(Request::METHOD_OPTIONS)) {
+ header('Access-Control-Allow-Methods: GET, OPTIONS');
+ return '';
+ }
+
+ return $next($request);
+ }
+}
diff --git a/src/Invoice/Infrastructure/Plugin/InvoiceRoutesPlugin.php b/src/Invoice/Infrastructure/Plugin/InvoiceRoutesPlugin.php
index 11e9209..b08c3a1 100644
--- a/src/Invoice/Infrastructure/Plugin/InvoiceRoutesPlugin.php
+++ b/src/Invoice/Infrastructure/Plugin/InvoiceRoutesPlugin.php
@@ -4,9 +4,15 @@
namespace PhpLightning\Invoice\Infrastructure\Plugin;
+use Exception;
+use Gacela\Router\Configure\Handlers;
+use Gacela\Router\Configure\Middlewares;
use Gacela\Router\Configure\Routes;
+use Gacela\Router\Entities\Request;
use Gacela\Router\RouterInterface;
use PhpLightning\Invoice\Infrastructure\Controller\InvoiceController;
+use PhpLightning\Invoice\Infrastructure\Handler\InvoiceExceptionHandler;
+use PhpLightning\Invoice\Infrastructure\Middleware\CorsMiddleware;
final readonly class InvoiceRoutesPlugin
{
@@ -17,8 +23,14 @@ public function __construct(
public function __invoke(): void
{
- $this->router->configure(static function (Routes $routes): void {
- $routes->get('{username?}', InvoiceController::class);
- });
+ $this->router->configure(
+ static function (Routes $routes, Middlewares $middlewares, Handlers $handlers): void {
+ // OPTIONS is registered so CORS preflight reaches CorsMiddleware,
+ // which short-circuits it before InvoiceController runs.
+ $routes->match([Request::METHOD_GET, Request::METHOD_OPTIONS], '{username?}', InvoiceController::class);
+ $middlewares->add(new CorsMiddleware());
+ $handlers->handle(Exception::class, new InvoiceExceptionHandler());
+ },
+ );
}
}
diff --git a/src/Invoice/InvoiceConfig.php b/src/Invoice/InvoiceConfig.php
index 11766ec..c242a4a 100644
--- a/src/Invoice/InvoiceConfig.php
+++ b/src/Invoice/InvoiceConfig.php
@@ -5,6 +5,7 @@
namespace PhpLightning\Invoice;
use Gacela\Framework\AbstractConfig;
+use PhpLightning\Shared\Config\ConfigKey;
use PhpLightning\Shared\Value\SendableRange;
use RuntimeException;
@@ -14,7 +15,7 @@ final class InvoiceConfig extends AbstractConfig
{
public function getCallback(): string
{
- return (string)$this->get('callback-url', 'undefined:callback-url');
+ return (string)$this->get(ConfigKey::CALLBACK_URL, 'undefined:callback-url');
}
public function getDefaultLnAddress(): string
@@ -28,7 +29,7 @@ public function getDefaultLnAddress(): string
public function getBackends(): array
{
/** @psalm-suppress MixedReturnTypeCoercion */
- return (array)$this->get('backends'); // @phpstan-ignore-line
+ return (array)$this->get(ConfigKey::BACKENDS); // @phpstan-ignore-line
}
/**
@@ -51,31 +52,31 @@ public function getBackendOptionsFor(string $username): array
public function getSendableRange(): SendableRange
{
- return $this->get('sendable-range', SendableRange::default());
+ return $this->get(ConfigKey::SENDABLE_RANGE, SendableRange::default());
}
public function getDescriptionTemplate(): string
{
- return (string)$this->get('description-template', 'Pay to %s');
+ return (string)$this->get(ConfigKey::DESCRIPTION_TEMPLATE, 'Pay to %s');
}
public function getSuccessMessage(): string
{
- return (string)$this->get('success-message', 'Payment received!');
+ return (string)$this->get(ConfigKey::SUCCESS_MESSAGE, 'Payment received!');
}
public function getInvoiceMemo(): string
{
- return (string)$this->get('invoice-memo', '');
+ return (string)$this->get(ConfigKey::INVOICE_MEMO, '');
}
public function getDomain(): string
{
- return (string)$this->get('domain', $_SERVER['HTTP_HOST'] ?? 'localhost');
+ return (string)$this->get(ConfigKey::DOMAIN, $_SERVER['HTTP_HOST'] ?? 'localhost');
}
private function getReceiver(): string
{
- return (string)$this->get('receiver', $_SERVER['REQUEST_URI'] ?? 'unknown-receiver');
+ return (string)$this->get(ConfigKey::RECEIVER, 'unknown-receiver');
}
}
diff --git a/src/Invoice/InvoiceDependencyProvider.php b/src/Invoice/InvoiceDependencyProvider.php
index 330a00f..31f7fed 100644
--- a/src/Invoice/InvoiceDependencyProvider.php
+++ b/src/Invoice/InvoiceDependencyProvider.php
@@ -8,6 +8,9 @@
use Gacela\Framework\Container\Container;
use PhpLightning\Invoice\Infrastructure\Http\HttpApi;
+/**
+ * @extends AbstractProvider
+ */
final class InvoiceDependencyProvider extends AbstractProvider
{
public const HTTP_API = 'HTTP_API';
diff --git a/src/Invoice/InvoiceFacade.php b/src/Invoice/InvoiceFacade.php
index 19a142d..e6c21eb 100644
--- a/src/Invoice/InvoiceFacade.php
+++ b/src/Invoice/InvoiceFacade.php
@@ -7,6 +7,8 @@
use Gacela\Framework\AbstractFacade;
/**
+ * @extends AbstractFacade
+ *
* @method InvoiceFactory getFactory()
*/
final class InvoiceFacade extends AbstractFacade
@@ -28,6 +30,17 @@ public function getCallbackUrl(string $username): array
->getCallbackUrl($username);
}
+ /**
+ * @return array{
+ * pr: string,
+ * status: string,
+ * memo: string,
+ * successAction: array{tag: string, message: string},
+ * routes: list,
+ * disposable: bool,
+ * error: string|null,
+ * }|array{status: string, reason: string}
+ */
public function generateInvoice(string $username, int $milliSats): array
{
return $this->getFactory()
diff --git a/src/Invoice/InvoiceFactory.php b/src/Invoice/InvoiceFactory.php
index 3418d45..e485f98 100644
--- a/src/Invoice/InvoiceFactory.php
+++ b/src/Invoice/InvoiceFactory.php
@@ -15,6 +15,8 @@
use PhpLightning\Invoice\Domain\Http\HttpApiInterface;
/**
+ * @extends AbstractFactory
+ *
* @method InvoiceConfig getConfig()
*/
final class InvoiceFactory extends AbstractFactory
diff --git a/src/Shared/Config/ConfigKey.php b/src/Shared/Config/ConfigKey.php
new file mode 100644
index 0000000..2e8f4e0
--- /dev/null
+++ b/src/Shared/Config/ConfigKey.php
@@ -0,0 +1,22 @@
+description()],
+ ['text/identifier', $this->lnAddress],
+ ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
+ }
+
+ public function description(): string
+ {
+ return sprintf($this->descriptionTemplate, $this->lnAddress);
+ }
+}
diff --git a/src/Shared/Value/SendableRange.php b/src/Shared/Value/SendableRange.php
index 931abcf..1c3fc09 100644
--- a/src/Shared/Value/SendableRange.php
+++ b/src/Shared/Value/SendableRange.php
@@ -18,6 +18,16 @@ private function __construct(
) {
}
+ /**
+ * Rehydrates the instance from Gacela's var_export'd merged-config file cache.
+ *
+ * @param array{min: int, max: int} $state
+ */
+ public static function __set_state(array $state): self
+ {
+ return new self($state['min'], $state['max']);
+ }
+
public static function default(): self
{
return self::withMinMax(
diff --git a/tests/Feature/Fake/FakeHttpApi.php b/tests/Feature/Fake/FakeHttpApi.php
index ee414ae..9acc2e4 100644
--- a/tests/Feature/Fake/FakeHttpApi.php
+++ b/tests/Feature/Fake/FakeHttpApi.php
@@ -8,7 +8,7 @@
final class FakeHttpApi implements HttpApiInterface
{
- public function postRequestInvoice(string $uri, string $body, array $headers = []): ?array
+ public function postRequestInvoice(string $uri, string $body, array $headers = []): array
{
return [
'checking_id' => '8efa8998bca6e298ae63dc7425c8a34b5373511d88a70f6f29ba98f833a63f04',
diff --git a/tests/Feature/InvoiceFacadeTest.php b/tests/Feature/InvoiceFacadeTest.php
index 7d573a3..6753912 100644
--- a/tests/Feature/InvoiceFacadeTest.php
+++ b/tests/Feature/InvoiceFacadeTest.php
@@ -48,7 +48,7 @@ public function test_ln_bits_feature(): void
$json = $this->facade->generateInvoice('alice', 2_000);
self::assertEquals([
- 'bolt11' => 'lnbc10u1p5r9lmwpp53magnx9u5m3f3tnrm36ztj9rfdfhx5ga3zns7mefh2v0svax8uzqcqzyssp54twf429a8cvz6tflw5lt705gfnvuykhdeewey009tugjcuamt38q9q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgqdqqmqz9gxqrrssrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glclll4ttz7sp6kpvqqqqlgqqqqqeqqjq0uu89sejjllry5ye43x0v42jn48c6alfc9mfnjla2u6kmwy444pzrjmtu25nk2shshuh2mrqtehygmzya9xg89ppszuuhd9296vvcxspkpwc68',
+ 'pr' => 'lnbc10u1p5r9lmwpp53magnx9u5m3f3tnrm36ztj9rfdfhx5ga3zns7mefh2v0svax8uzqcqzyssp54twf429a8cvz6tflw5lt705gfnvuykhdeewey009tugjcuamt38q9q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgqdqqmqz9gxqrrssrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glclll4ttz7sp6kpvqqqqlgqqqqqeqqjq0uu89sejjllry5ye43x0v42jn48c6alfc9mfnjla2u6kmwy444pzrjmtu25nk2shshuh2mrqtehygmzya9xg89ppszuuhd9296vvcxspkpwc68',
'status' => 'pending',
'successAction' => [
'tag' => 'message',
@@ -71,7 +71,7 @@ private function bootstrapGacela(): void
->setDomain('domain.com')
->setReceiver('receiver')
->setSendableRange(1_000, 10_000)
- ->addBackendsFile(__DIR__ . DIRECTORY_SEPARATOR . 'nostr.json')
+ ->addBackendsFile(__DIR__ . DIRECTORY_SEPARATOR . 'backends.json')
->jsonSerialize(),
);
});
diff --git a/tests/Feature/nostr.json b/tests/Feature/backends.json
similarity index 100%
rename from tests/Feature/nostr.json
rename to tests/Feature/backends.json
diff --git a/tests/Unit/Config/Backend/BackendTypeTest.php b/tests/Unit/Config/Backend/BackendTypeTest.php
new file mode 100644
index 0000000..6de2aef
--- /dev/null
+++ b/tests/Unit/Config/Backend/BackendTypeTest.php
@@ -0,0 +1,25 @@
+expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Unknown backend type "paypal". Supported types: lnbits');
+
+ BackendType::fromString('paypal');
+ }
+}
diff --git a/tests/Unit/Config/LightningConfigTest.php b/tests/Unit/Config/LightningConfigTest.php
index f3e8041..f305273 100644
--- a/tests/Unit/Config/LightningConfigTest.php
+++ b/tests/Unit/Config/LightningConfigTest.php
@@ -4,9 +4,11 @@
namespace PhpLightningTest\Unit\Config;
+use PhpLightning\Config\Backend\LnBitsBackendConfig;
use PhpLightning\Config\LightningConfig;
use PhpLightning\Shared\Value\SendableRange;
use PHPUnit\Framework\TestCase;
+use RuntimeException;
final class LightningConfigTest extends TestCase
{
@@ -68,4 +70,41 @@ public function test_description_and_success_message(): void
'success-message' => 'Thanks!',
], $config->jsonSerialize());
}
+
+ public function test_add_backend_programmatically_without_a_file(): void
+ {
+ $config = (new LightningConfig())
+ ->addBackend('bob', LnBitsBackendConfig::withEndpointAndKey('http://localhost:5000', 'key-123'));
+
+ self::assertSame([
+ 'backends' => [
+ 'bob' => [
+ 'api_endpoint' => 'http://localhost:5000',
+ 'api_key' => 'key-123',
+ ],
+ ],
+ ], $config->jsonSerialize());
+ }
+
+ public function test_add_backends_file_reports_missing_path(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Backends file not found: "/does/not/exist.json"');
+
+ (new LightningConfig())->addBackendsFile('/does/not/exist.json');
+ }
+
+ public function test_add_backends_file_reports_missing_type(): void
+ {
+ $path = (string)tempnam(sys_get_temp_dir(), 'lnaddr');
+ file_put_contents($path, (string)json_encode(['bob' => ['api_key' => 'x']]));
+
+ try {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Missing "type" for backend "bob"');
+ (new LightningConfig())->addBackendsFile($path);
+ } finally {
+ unlink($path);
+ }
+ }
}
diff --git a/tests/Unit/Invoice/Domain/BackendInvoice/LnbitsBackendInvoiceTest.php b/tests/Unit/Invoice/Domain/BackendInvoice/LnbitsBackendInvoiceTest.php
index a9bd230..255f18b 100644
--- a/tests/Unit/Invoice/Domain/BackendInvoice/LnbitsBackendInvoiceTest.php
+++ b/tests/Unit/Invoice/Domain/BackendInvoice/LnbitsBackendInvoiceTest.php
@@ -22,7 +22,7 @@ public function test_request_invoice_when_api_returns_null(): void
]);
$actual = $invoice->requestInvoice(100, '', '');
- $expected = new InvoiceTransfer(error: 'Backend "LnBits" unreachable', status: 'ERROR');
+ $expected = new InvoiceTransfer(status: 'ERROR', error: 'Backend "LnBits" unreachable');
self::assertEquals($expected, $actual);
}
diff --git a/tests/Unit/Invoice/Domain/LnAddress/InvoiceGeneratorTest.php b/tests/Unit/Invoice/Domain/LnAddress/InvoiceGeneratorTest.php
index d08c772..a18b650 100644
--- a/tests/Unit/Invoice/Domain/LnAddress/InvoiceGeneratorTest.php
+++ b/tests/Unit/Invoice/Domain/LnAddress/InvoiceGeneratorTest.php
@@ -37,7 +37,7 @@ public function test_unknown_backend(): void
{
$invoiceFacade = $this->createStub(BackendInvoiceInterface::class);
$invoiceFacade->method('requestInvoice')
- ->willReturn(new InvoiceTransfer(error: 'some reason', status: 'ERROR'));
+ ->willReturn(new InvoiceTransfer(status: 'ERROR', error: 'some reason'));
$invoice = new InvoiceGenerator(
$invoiceFacade,
@@ -50,7 +50,7 @@ public function test_unknown_backend(): void
$actual = $invoice->generateInvoice(2_000);
self::assertEquals([
- 'bolt11' => '',
+ 'pr' => '',
'status' => 'ERROR',
'memo' => '',
'successAction' => [
@@ -80,7 +80,7 @@ public function test_successful_payment_request_with_amount(): void
$actual = $invoice->generateInvoice(2_000);
self::assertEquals([
- 'bolt11' => 'ln123456789',
+ 'pr' => 'ln123456789',
'status' => 'OK',
'memo' => 'Custom memo',
'successAction' => [
diff --git a/tests/Unit/Invoice/Infrastructure/Handler/InvoiceExceptionHandlerTest.php b/tests/Unit/Invoice/Infrastructure/Handler/InvoiceExceptionHandlerTest.php
new file mode 100644
index 0000000..f97496f
--- /dev/null
+++ b/tests/Unit/Invoice/Infrastructure/Handler/InvoiceExceptionHandlerTest.php
@@ -0,0 +1,27 @@
+ 'ERROR',
+ 'reason' => 'Missing backend options for bob',
+ ], json_decode($json, true, flags: JSON_THROW_ON_ERROR));
+ }
+}
diff --git a/tests/Unit/Invoice/Infrastructure/Middleware/CorsMiddlewareTest.php b/tests/Unit/Invoice/Infrastructure/Middleware/CorsMiddlewareTest.php
new file mode 100644
index 0000000..9376ed1
--- /dev/null
+++ b/tests/Unit/Invoice/Infrastructure/Middleware/CorsMiddlewareTest.php
@@ -0,0 +1,51 @@
+handle(
+ Request::fromGlobals(),
+ static function () use (&$nextCalled): string {
+ $nextCalled = true;
+ return 'NEXT';
+ },
+ );
+
+ self::assertSame('', $result);
+ self::assertFalse($nextCalled);
+ }
+
+ /**
+ * @runInSeparateProcess
+ *
+ * @preserveGlobalState disabled
+ */
+ public function test_get_request_delegates_to_next(): void
+ {
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+
+ $result = (new CorsMiddleware())->handle(
+ Request::fromGlobals(),
+ static fn (): string => 'NEXT',
+ );
+
+ self::assertSame('NEXT', $result);
+ }
+}
diff --git a/tests/Unit/Shared/Value/LnurlPayMetadataTest.php b/tests/Unit/Shared/Value/LnurlPayMetadataTest.php
new file mode 100644
index 0000000..e40f76f
--- /dev/null
+++ b/tests/Unit/Shared/Value/LnurlPayMetadataTest.php
@@ -0,0 +1,44 @@
+description());
+ }
+
+ /**
+ * A quote in the description used to break the hand-built JSON string;
+ * json_encode escapes it so the output stays parseable per LUD-06.
+ */
+ public function test_escapes_quotes_to_stay_valid_json(): void
+ {
+ $metadata = new LnurlPayMetadata('Pay "now" to %s', 'bob@domain.com');
+
+ $decoded = json_decode((string)$metadata, true, flags: JSON_THROW_ON_ERROR);
+
+ self::assertSame([
+ ['text/plain', 'Pay "now" to bob@domain.com'],
+ ['text/identifier', 'bob@domain.com'],
+ ], $decoded);
+ }
+}