├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── ci.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── LICENSE ├── README.md ├── composer.json ├── gacela.php ├── images └── lnaddr_workflow.png ├── lightning-config.dist.php ├── nostr.dist.json ├── phpstan.neon ├── phpunit.xml ├── psalm.xml ├── public └── index.php ├── rector.php ├── src ├── Config │ ├── Backend │ │ ├── BackendConfigInterface.php │ │ └── LnBitsBackendConfig.php │ ├── BackendsConfig.php │ └── LightningConfig.php ├── Invoice │ ├── Application │ │ ├── CallbackUrl.php │ │ └── InvoiceGenerator.php │ ├── Domain │ │ ├── BackendInvoice │ │ │ ├── BackendInvoiceInterface.php │ │ │ ├── EmptyBackendInvoice.php │ │ │ └── LnbitsBackendInvoice.php │ │ ├── CallbackUrl │ │ │ ├── CallbackUrlInterface.php │ │ │ ├── LnAddressGenerator.php │ │ │ └── LnAddressGeneratorInterface.php │ │ └── Http │ │ │ └── HttpApiInterface.php │ ├── Infrastructure │ │ ├── Controller │ │ │ └── InvoiceController.php │ │ ├── Http │ │ │ └── HttpApi.php │ │ └── Plugin │ │ │ └── InvoiceRoutesPlugin.php │ ├── InvoiceConfig.php │ ├── InvoiceDependencyProvider.php │ ├── InvoiceFacade.php │ └── InvoiceFactory.php └── Shared │ ├── Transfer │ ├── InvoiceExtraTransfer.php │ └── InvoiceTransfer.php │ └── Value │ └── SendableRange.php └── tests ├── Feature ├── Fake │ └── FakeHttpApi.php ├── InvoiceFacadeTest.php └── nostr.json └── Unit ├── Config └── LightningConfigTest.php └── Invoice └── Domain ├── BackendInvoice └── LnbitsBackendInvoiceTest.php ├── CallbackUrl └── CallbackUrlTest.php └── LnAddress └── InvoiceGeneratorTest.php /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we 4 | pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, 5 | submitting pull requests or patches, and other activities. 6 | 7 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level 8 | of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, 9 | race, ethnicity, age, religion, or nationality. 10 | 11 | Examples of unacceptable behavior by participants include: 12 | 13 | * The use of sexualized language or imagery 14 | * Personal attacks 15 | * Trolling or insulting/derogatory comments 16 | * Public or private harassment 17 | * Publishing other's private information, such as physical or electronic addresses, without explicit permission 18 | * Other unethical or unprofessional conduct 19 | 20 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, 21 | issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any 22 | contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 23 | 24 | By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these 25 | principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of 26 | Conduct may be permanently removed from the project team. 27 | 28 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the 29 | project or its community. 30 | 31 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project maintainer 32 | at chemaclass@outlook.es. All complaints will be reviewed and investigated and will result in a response that is deemed 33 | necessary and appropriate to the circumstances. Maintainers are obligated to maintain confidentiality with regard to the 34 | reporter of an incident. 35 | 36 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.3.0, available 37 | at [https://contributor-covenant.org/version/1/3/0/][version] 38 | 39 | [homepage]: https://contributor-covenant.org 40 | 41 | [version]: https://contributor-covenant.org/version/1/3/0/ 42 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Welcome! 4 | 5 | We look forward to your contributions! 6 | 7 | ## We have a Code of Conduct 8 | 9 | Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. 10 | 11 | ## Any contributions you make will be under the MIT License 12 | 13 | When you submit code changes, your submissions are understood to be under the same [MIT](https://github.com/php-lightning/lnaddress/blob/master/LICENSE) that covers the project. By contributing to this project, you agree that your contributions will be licensed under its MIT. 14 | 15 | ## Write bug reports with detail, background, and sample code 16 | 17 | In your bug report, please provide the following: 18 | 19 | * A quick summary and/or background 20 | * Steps to reproduce 21 | * Be specific! 22 | * Give sample code if you can. 23 | * What you expected would happen 24 | * What actually happens 25 | * Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 26 | 27 | Please post code and output as text ([using proper markup](https://guides.github.com/features/mastering-markdown/)). 28 | Do not post screenshots of code or output. 29 | 30 | ## Workflow for Pull Requests 31 | 32 | 1. Fork/clone the repository. 33 | 2. Install the vendor dependencies with `composer update`. 34 | 3. Create your branch from `main` if you plan to implement new functionality or change existing code significantly; 35 | create your branch from the oldest branch that is affected by the bug if you plan to fix a bug. 36 | 4. Implement your change and add tests for it. 37 | 5. Ensure the test suite passes. 38 | 6. Ensure the code complies with our coding guidelines (see below). 39 | 7. Send that pull request! 40 | 41 | Please make sure you have [set up your username and email address](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup) for use with Git. Strings such as `silly nick name ` look really stupid in the commit history of a project. 42 | 43 | ## Coding Guidelines 44 | 45 | This project comes with some configuration files (located at `/psalm.xml` & `/phpstan.neon`) that you can use to perform static analysis (with a focus on type checking): 46 | 47 | ```bash 48 | $ ./vendor/bin/psalm 49 | $ ./vendor/bin/phpstan 50 | ``` 51 | 52 | This project comes with a configuration file (located at `/.php-cs-fixer.dist.php` in the repository) that you can use to (re)format your source code for compliance with this project's coding guidelines: 53 | 54 | ```bash 55 | $ ./vendor/bin/php-cs-fixer fix 56 | ``` 57 | 58 | Please understand that we will not accept a pull request when its changes violate this project's coding guidelines. 59 | 60 | ## Running Gacela's test suite 61 | 62 | Once you've installed all composer dependencies, you can simply test all suites running the following composer script: 63 | 64 | ```bash 65 | $ composer test-all 66 | ``` 67 | 68 | You can see more composer scripts inside the `/composer.json` file. 69 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ["https://chemaclass.com/sponsor"] 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 📚 Description 2 | 3 | Replace this text with a short description of your feature/bugfix. 4 | 5 | ## 🔖 Changes 6 | 7 | - List individual changes in more detail 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/en/categories/automating-your-workflow-with-github-actions 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | name: CI 10 | 11 | jobs: 12 | 13 | coding-guidelines: 14 | name: Coding Guidelines 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: 8.2 22 | coverage: none 23 | tools: composer 24 | 25 | - name: Get composer cache directory 26 | id: composer-cache 27 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 28 | 29 | - name: Cache dependencies 30 | uses: actions/cache@v4 31 | with: 32 | path: ${{ steps.composer-cache.outputs.dir }} 33 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 34 | restore-keys: ${{ runner.os }}-composer- 35 | 36 | - name: Install dependencies 37 | run: composer install --no-interaction --no-ansi --no-progress 38 | 39 | - name: Run friendsofphp/php-cs-fixer 40 | run: ./vendor/bin/php-cs-fixer fix --allow-risky=yes --dry-run --show-progress=dots --using-cache=no --verbose 41 | 42 | type-checker: 43 | name: Type Checker 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | 48 | - uses: shivammathur/setup-php@v2 49 | with: 50 | php-version: 8.2 51 | coverage: none 52 | tools: composer 53 | 54 | - name: Get composer cache directory 55 | id: composer-cache 56 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 57 | 58 | - name: Cache dependencies 59 | uses: actions/cache@v4 60 | with: 61 | path: ${{ steps.composer-cache.outputs.dir }} 62 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 63 | restore-keys: ${{ runner.os }}-composer- 64 | 65 | - name: Install dependencies 66 | run: composer install --no-interaction --no-ansi --no-progress 67 | 68 | - name: Run psalm 69 | run: ./vendor/bin/psalm --config=psalm.xml --no-progress --shepherd --show-info=false --stats --output-format=github 70 | 71 | - name: Run phpstan 72 | run: ./vendor/bin/phpstan analyze -c phpstan.neon src 73 | 74 | tests: 75 | name: "PHP version ${{ matrix.php-version }}, deps. ${{ matrix.dependencies }}" 76 | runs-on: ${{ matrix.operating-system }} 77 | strategy: 78 | fail-fast: false 79 | matrix: 80 | dependencies: [ "locked", "highest" ] 81 | php-version: [ "8.2" , "8.2", "8.3" ] 82 | operating-system: 83 | - "ubuntu-latest" 84 | steps: 85 | - uses: actions/checkout@v4 86 | 87 | - uses: shivammathur/setup-php@v2 88 | with: 89 | php-version: ${{ matrix.php-version }} 90 | coverage: none 91 | tools: composer 92 | 93 | - name: Get composer cache directory 94 | id: composercache 95 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 96 | 97 | - name: Cache dependencies 98 | uses: actions/cache@v4 99 | with: 100 | path: ${{ steps.composercache.outputs.dir }} 101 | key: "php-${{ matrix.php-version }}-${{ matrix.dependencies }}-${{ hashFiles('**/composer.lock') }}" 102 | restore-keys: "php-${{ matrix.php-version }}-${{ matrix.dependencies }}-${{ hashFiles('**/composer.lock') }}" 103 | 104 | - name: "Install lowest dependencies" 105 | if: ${{ matrix.dependencies == 'lowest' }} 106 | run: "composer update --prefer-lowest --no-interaction --no-progress" 107 | 108 | - name: "Install locked dependencies" 109 | if: ${{ matrix.dependencies == 'locked' }} 110 | run: "composer install --no-interaction --no-progress" 111 | 112 | - name: "Install highest dependencies" 113 | if: ${{ matrix.dependencies == 'highest' }} 114 | run: "composer update --no-interaction --no-progress" 115 | 116 | - name: Run unit tests 117 | run: vendor/bin/phpunit 118 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | .cache/ 4 | .gacela/ 5 | coverage/ 6 | data/ 7 | vendor/ 8 | var/ 9 | 10 | *.cache 11 | composer.lock 12 | lightning-config.php 13 | nostr.json 14 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | files() 11 | ->in(__DIR__ . '/public') 12 | ->in(__DIR__ . '/src') 13 | ->in(__DIR__ . '/tests'); 14 | 15 | return (new Config()) 16 | ->setParallelConfig(ParallelConfigFactory::detect()) 17 | ->setFinder($finder) 18 | ->setRiskyAllowed(true) 19 | ->setRules([ 20 | '@PSR12' => true, 21 | 'array_syntax' => ['syntax' => 'short'], 22 | 'backtick_to_shell_exec' => true, 23 | 'single_space_around_construct' => true, 24 | 'control_structure_braces' => true, 25 | 'control_structure_continuation_position' => true, 26 | 'declare_parentheses' => true, 27 | 'no_multiple_statements_per_line' => true, 28 | 'braces_position' => true, 29 | 'statement_indentation' => true, 30 | 'no_extra_blank_lines' => true, 31 | 'class_definition' => ['single_line' => true], 32 | 'concat_space' => ['spacing' => 'one'], 33 | 'declare_strict_types' => true, 34 | 'elseif' => true, 35 | 'encoding' => true, 36 | 'ereg_to_preg' => true, 37 | 'explicit_string_variable' => true, 38 | 'fully_qualified_strict_types' => true, 39 | 'type_declaration_spaces' => true, 40 | 'general_phpdoc_annotation_remove' => [ 41 | 'annotations' => [ 42 | 'author', 43 | 'package', 44 | 'subpackage', 45 | 'version', 46 | ], 47 | ], 48 | 'global_namespace_import' => [ 49 | 'import_functions' => true, 50 | ], 51 | 'include' => true, 52 | 'increment_style' => ['style' => 'pre'], 53 | 'list_syntax' => ['syntax' => 'short'], 54 | 'native_function_invocation' => [ 55 | 'include' => ['@compiler_optimized'], 56 | ], 57 | 'native_type_declaration_casing' => true, 58 | 'new_with_parentheses' => true, 59 | 'no_blank_lines_after_class_opening' => true, 60 | 'no_empty_comment' => true, 61 | 'no_empty_phpdoc' => true, 62 | 'no_empty_statement' => true, 63 | 'no_homoglyph_names' => true, 64 | 'no_leading_import_slash' => true, 65 | 'no_leading_namespace_whitespace' => true, 66 | 'no_mixed_echo_print' => ['use' => 'echo'], 67 | 'no_multiline_whitespace_around_double_arrow' => true, 68 | 'no_singleline_whitespace_before_semicolons' => true, 69 | 'no_short_bool_cast' => true, 70 | 'no_trailing_comma_in_singleline' => true, 71 | 'no_trailing_whitespace' => true, 72 | 'no_trailing_whitespace_in_comment' => true, 73 | 'no_useless_else' => true, 74 | 'no_useless_return' => true, 75 | 'no_whitespace_before_comma_in_array' => true, 76 | 'no_whitespace_in_blank_line' => true, 77 | 'no_unused_imports' => true, 78 | 'non_printable_character' => [ 79 | 'use_escape_sequences_in_strings' => true, 80 | ], 81 | 'normalize_index_brace' => true, 82 | 'object_operator_without_whitespace' => true, 83 | 'ordered_class_elements' => true, 84 | 'ordered_imports' => [ 85 | 'imports_order' => [ 86 | 'class', 87 | 'function', 88 | 'const', 89 | ], 90 | 'sort_algorithm' => 'alpha', 91 | ], 92 | 'php_unit_construct' => true, 93 | 'php_unit_dedicate_assert' => true, 94 | 'php_unit_dedicate_assert_internal_type' => true, 95 | 'php_unit_method_casing' => ['case' => 'snake_case'], 96 | 'phpdoc_add_missing_param_annotation' => true, 97 | 'phpdoc_annotation_without_dot' => true, 98 | 'phpdoc_indent' => true, 99 | 'phpdoc_line_span' => ['const' => 'single', 'property' => 'single', 'method' => 'multi'], 100 | 'phpdoc_order' => true, 101 | 'phpdoc_scalar' => true, 102 | 'phpdoc_separation' => true, 103 | 'phpdoc_summary' => false, 104 | 'phpdoc_trim' => true, 105 | 'phpdoc_types' => true, 106 | 'phpdoc_var_annotation_correct_order' => true, 107 | 'phpdoc_var_without_name' => true, 108 | 'self_accessor' => true, 109 | 'single_quote' => true, 110 | 'short_scalar_cast' => true, 111 | 'standardize_increment' => true, 112 | 'standardize_not_equals' => true, 113 | 'static_lambda' => true, 114 | 'switch_case_semicolon_to_colon' => true, 115 | 'switch_case_space' => true, 116 | 'switch_continue_to_break' => true, 117 | 'ternary_operator_spaces' => true, 118 | 'ternary_to_elvis_operator' => true, 119 | 'ternary_to_null_coalescing' => true, 120 | 'trailing_comma_in_multiline' => [ 121 | 'elements' => [ 122 | 'arrays', 123 | 'arguments', 124 | 'parameters', 125 | ], 126 | ], 127 | 'trim_array_spaces' => true, 128 | 'unary_operator_spaces' => true, 129 | 'types_spaces' => true, 130 | 'visibility_required' => true, 131 | 'void_return' => true, 132 | 'yoda_style' => [ 133 | 'equal' => false, 134 | 'identical' => false, 135 | 'less_and_greater' => null, 136 | ], 137 | ]); 138 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Lightning Address 2 | 3 | PHP Lightning Address is an easy way to get a [lightning address](https://lightningaddress.com/) in PHP. 4 | 5 |

6 | 7 | GitHub Build Status 8 | 9 | 10 | Scrutinizer Code Quality 11 | 12 | 13 | Scrutinizer Code Coverage 14 | 15 | 16 | Psalm Type-coverage Status 17 | 18 | 19 | MIT Software License 20 | 21 |

22 | 23 | ## Usage / Development 24 | 25 | Set up your custom config: 26 | 27 | ```bash 28 | cp lightning-config.dist.php lightning-config.php 29 | # or just simply the nostr.json to define the backends/user-settings 30 | cp nostr.dist.json nostr.json 31 | ``` 32 | 33 | You can customize the invoice description and the success message by editing 34 | `lightning-config.php`: 35 | 36 | ```php 37 | use PhpLightning\Config\LightningConfig; 38 | 39 | return (new LightningConfig()) 40 | ->setDescriptionTemplate('Pay to %s on mynode') 41 | ->setSuccessMessage('Thanks for the payment!'); 42 | ``` 43 | 44 | Run a local PHP server listening `public/index.php` 45 | 46 | ```bash 47 | composer serve 48 | ``` 49 | 50 | ### Demo template 51 | 52 | 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. 53 | 54 | > [https://github.com/php-lightning/demo-lnaddress](https://github.com/php-lightning/demo-lnaddress) 55 | 56 | ## Wiki 57 | 58 | Check the wiki for more details: [https://github.com/php-lightning/lnaddress/wiki](https://github.com/php-lightning/lnaddress/wiki) 59 | 60 | ## Contributions 61 | 62 | Feel free to open issues & PR if you want to contribute to this project. 63 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "php-lightning/lnaddress", 3 | "type": "library", 4 | "license": "MIT", 5 | "require": { 6 | "php": ">=8.2", 7 | "gacela-project/gacela": "^1.9", 8 | "gacela-project/router": "^0.12", 9 | "symfony/http-client": "^7.2" 10 | }, 11 | "require-dev": { 12 | "friendsofphp/php-cs-fixer": "^3.75", 13 | "gacela-project/phpstan-extension": "^0.3", 14 | "phpstan/phpstan": "^1.12", 15 | "phpunit/phpunit": "^9.6", 16 | "psalm/plugin-phpunit": "^0.19", 17 | "rector/rector": "^1.2", 18 | "symfony/var-dumper": "^7.2", 19 | "vimeo/psalm": "^6.11" 20 | }, 21 | "config": { 22 | "platform": { 23 | "php": "8.2.27" 24 | }, 25 | "allow-plugins": { 26 | "composer/package-versions-deprecated": true 27 | }, 28 | "optimize-autoloader": true, 29 | "preferred-install": "dist", 30 | "sort-packages": true 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "PhpLightning\\": "src/" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "PhpLightningTest\\": "tests/" 40 | } 41 | }, 42 | "scripts": { 43 | "post-install-cmd": [ 44 | "[ ! -f nostr.json ] && cp nostr.dist.json nostr.json || true" 45 | ], 46 | "ctal": [ 47 | "@static-clear-cache", 48 | "@csfix", 49 | "@test-all" 50 | ], 51 | "fix": ["@csfix", "@rector"], 52 | "test": "@test-all", 53 | "test-all": [ 54 | "@quality", 55 | "@phpunit", 56 | "@rector:dry" 57 | ], 58 | "quality": [ 59 | "@csrun", 60 | "@psalm", 61 | "@phpstan" 62 | ], 63 | "phpunit": [ 64 | "@test-phpunit" 65 | ], 66 | "static-clear-cache": [ 67 | "XDEBUG_MODE=off vendor/bin/psalm --clear-cache", 68 | "XDEBUG_MODE=off vendor/bin/phpstan clear-result-cache" 69 | ], 70 | "test-phpunit": "XDEBUG_MODE=off ./vendor/bin/phpunit", 71 | "test-coverage": "XDEBUG_MODE=coverage ./vendor/bin/phpunit --testsuite=unit,feature --coverage-html=data/coverage-html", 72 | "psalm": "XDEBUG_MODE=off ./vendor/bin/psalm", 73 | "phpstan": "XDEBUG_MODE=off ./vendor/bin/phpstan analyze", 74 | "csfix": "XDEBUG_MODE=off ./vendor/bin/php-cs-fixer fix", 75 | "csrun": "XDEBUG_MODE=off ./vendor/bin/php-cs-fixer fix --dry-run", 76 | "rector": "./vendor/bin/rector", 77 | "rector:dry": "./vendor/bin/rector --dry-run", 78 | "serve": "php -S localhost:8080 public/index.php" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /gacela.php: -------------------------------------------------------------------------------- 1 | enableFileCache() 12 | ->addAppConfig('lightning-config.dist.php', 'lightning-config.php') 13 | ->extendGacelaConfig(RouterGacelaConfig::class) 14 | ->addPlugin(InvoiceRoutesPlugin::class); 15 | }; 16 | -------------------------------------------------------------------------------- /images/lnaddr_workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/php-lightning/lnaddress/aae9fe91d466387b219abb350517fad9cbdcffa2/images/lnaddr_workflow.png -------------------------------------------------------------------------------- /lightning-config.dist.php: -------------------------------------------------------------------------------- 1 | setDomain('localhost') 9 | ->setReceiver('default-receiver') 10 | ->setDescriptionTemplate('Pay to %s') 11 | ->setSuccessMessage('Payment received!') 12 | ->setInvoiceMemo('') 13 | ->setSendableRange(min: 100_000, max: 10_000_000_000) 14 | ->setCallbackUrl('localhost:8000/callback') 15 | ->addBackendsFile(getcwd() . DIRECTORY_SEPARATOR . 'nostr.json'); 16 | -------------------------------------------------------------------------------- /nostr.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "bob": { 3 | "type": "lnbits", 4 | "api_key": "abc...123", 5 | "api_endpoint": "http://localhost:5000" 6 | }, 7 | "alice": { 8 | "type": "lnbits", 9 | "api_key": "def...456", 10 | "api_endpoint": "http://localhost:5000" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/gacela-project/phpstan-extension/extension.neon 3 | 4 | parameters: 5 | level: max 6 | paths: 7 | - %currentWorkingDirectory%/src/ 8 | 9 | gacela: 10 | modulesNamespace: PhpLightning 11 | excludedNamespaces: 12 | - PhpLightning\Shared 13 | 14 | ignoreErrors: 15 | - identifier: missingType.iterableValue 16 | - '#Cannot cast mixed to .*.#' 17 | - '#Method PhpLightning\\.*::.* should return array<.*> but returns array#' 18 | - '#Method PhpLightning\\.*::.* should return .* but returns mixed#' 19 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | tests/Unit 23 | 24 | 25 | tests/Feature 26 | 27 | 28 | 29 | 30 | 31 | src 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | run(); 21 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([ 9 | __DIR__.'/src', 10 | __DIR__.'/tests', 11 | ]) 12 | ->withPhpSets(php82: true) 13 | ->withPreparedSets( 14 | deadCode: true, 15 | codeQuality: true, 16 | ) 17 | ->withImportNames(); 18 | -------------------------------------------------------------------------------- /src/Config/Backend/BackendConfigInterface.php: -------------------------------------------------------------------------------- 1 | setApiEndpoint($endpoint) 20 | ->setApiKey($key); 21 | } 22 | 23 | /** 24 | * @return array{ 25 | * api_endpoint: string, 26 | * api_key: string 27 | * } 28 | */ 29 | public function jsonSerialize(): array 30 | { 31 | return [ 32 | 'api_endpoint' => $this->apiEndpoint, 33 | 'api_key' => $this->apiKey, 34 | ]; 35 | } 36 | 37 | private function setApiEndpoint(string $apiEndpoint): self 38 | { 39 | $this->apiEndpoint = rtrim($apiEndpoint, '/'); 40 | return $this; 41 | } 42 | 43 | private function setApiKey(string $apiKey): self 44 | { 45 | $this->apiKey = $apiKey; 46 | return $this; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Config/BackendsConfig.php: -------------------------------------------------------------------------------- 1 | */ 13 | private array $configs = []; 14 | 15 | public function add(string $username, BackendConfigInterface $backendConfig): self 16 | { 17 | $this->configs[$username] = $backendConfig; 18 | return $this; 19 | } 20 | 21 | /** 22 | * @psalm-suppress MixedReturnTypeCoercion 23 | * 24 | * @return array 25 | */ 26 | public function jsonSerialize(): array 27 | { 28 | /** @var array $result */ 29 | $result = []; 30 | 31 | foreach ($this->configs as $username => $config) { 32 | /** @psalm-suppress MixedAssignment */ 33 | $result[$username] = $config->jsonSerialize(); 34 | } 35 | 36 | return $result; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Config/LightningConfig.php: -------------------------------------------------------------------------------- 1 | domain = $parseUrl['host'] ?? $domain; 27 | return $this; 28 | } 29 | 30 | public function setReceiver(string $receiver): self 31 | { 32 | $this->receiver = $receiver; 33 | return $this; 34 | } 35 | 36 | public function setSendableRange(int $min, int $max): self 37 | { 38 | $this->sendableRange = SendableRange::withMinMax($min, $max); 39 | return $this; 40 | } 41 | 42 | public function setCallbackUrl(string $callbackUrl): self 43 | { 44 | $this->callbackUrl = $callbackUrl; 45 | return $this; 46 | } 47 | 48 | public function setDescriptionTemplate(string $template): self 49 | { 50 | $this->descriptionTemplate = $template; 51 | return $this; 52 | } 53 | 54 | public function setSuccessMessage(string $message): self 55 | { 56 | $this->successMessage = $message; 57 | return $this; 58 | } 59 | 60 | public function setInvoiceMemo(string $memo): self 61 | { 62 | $this->invoiceMemo = $memo; 63 | return $this; 64 | } 65 | 66 | public function addBackendsFile(string $path): self 67 | { 68 | $this->backends ??= new BackendsConfig(); 69 | 70 | $jsonAsString = (string)file_get_contents($path); 71 | /** @var array $json 76 | */ 77 | $json = json_decode($jsonAsString, true); 78 | 79 | foreach ($json as $user => $settings) { 80 | if (!isset($settings['type'])) { 81 | throw new RuntimeException('"type" missing'); 82 | } 83 | 84 | if ($settings['type'] === 'lnbits') { // TODO: refactor 85 | $this->backends->add( 86 | $user, 87 | LnBitsBackendConfig::withEndpointAndKey( 88 | $settings['api_endpoint'] ?? '', 89 | $settings['api_key'] ?? '', 90 | ), 91 | ); 92 | } 93 | } 94 | 95 | return $this; 96 | } 97 | 98 | public function jsonSerialize(): array 99 | { 100 | $result = []; 101 | if ($this->backends instanceof BackendsConfig) { 102 | $result['backends'] = $this->backends->jsonSerialize(); 103 | } 104 | if ($this->domain !== null) { 105 | $result['domain'] = $this->domain; 106 | } 107 | if ($this->receiver !== null) { 108 | $result['receiver'] = $this->receiver; 109 | } 110 | if ($this->sendableRange instanceof SendableRange) { 111 | $result['sendable-range'] = $this->sendableRange; 112 | } 113 | if ($this->callbackUrl !== null) { 114 | $result['callback-url'] = $this->callbackUrl; 115 | } 116 | if ($this->descriptionTemplate !== null) { 117 | $result['description-template'] = $this->descriptionTemplate; 118 | } 119 | if ($this->successMessage !== null) { 120 | $result['success-message'] = $this->successMessage; 121 | } 122 | if ($this->invoiceMemo !== null) { 123 | $result['invoice-memo'] = $this->invoiceMemo; 124 | } 125 | 126 | return $result; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Invoice/Application/CallbackUrl.php: -------------------------------------------------------------------------------- 1 | lnAddressGenerator->generate($username); 28 | // Modify the description if you want to custom it 29 | // This will be the description on the wallet that pays your ln address 30 | // TODO: Make this customizable from some external configuration file 31 | $description = sprintf($this->descriptionTemplate, $lnAddress); 32 | 33 | // TODO: images not implemented yet; `',["image/jpeg;base64","' . base64_encode($response) . '"]';` 34 | $imageMetadata = ''; 35 | $metadata = '[["text/plain","' . $description . '"],["text/identifier","' . $lnAddress . '"]' . $imageMetadata . ']'; 36 | 37 | // payRequest json data, spec : https://github.com/lnurl/luds/blob/luds/06.md 38 | return [ 39 | 'callback' => $this->callback, 40 | 'maxSendable' => $this->sendableRange->max(), 41 | 'minSendable' => $this->sendableRange->min(), 42 | 'metadata' => $metadata, 43 | 'tag' => self::TAG_PAY_REQUEST, 44 | 'commentAllowed' => false, // TODO: Not implemented yet 45 | ]; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Invoice/Application/InvoiceGenerator.php: -------------------------------------------------------------------------------- 1 | sendableRange->contains($milliSats)) { 29 | return [ 30 | 'status' => 'ERROR', 31 | 'reason' => 'Amount is not between minimum and maximum sendable amount', 32 | ]; 33 | } 34 | $description = sprintf($this->descriptionTemplate, $this->lnAddress); 35 | 36 | // TODO: images not implemented yet 37 | $imageMetadata = ''; 38 | $metadata = '[["text/plain","' . $description . '"],["text/identifier","' . $this->lnAddress . '"]' . $imageMetadata . ']'; 39 | 40 | $invoice = $this->backendInvoice->requestInvoice((int)($milliSats / 1000), $metadata, $this->memo); 41 | 42 | return $this->mapResponseAsArray($invoice); 43 | } 44 | 45 | private function mapResponseAsArray(InvoiceTransfer $invoice): array 46 | { 47 | return [ 48 | 'bolt11' => $invoice->bolt11, 49 | 'status' => $invoice->status, 50 | 'memo' => $invoice->memo, 51 | 'successAction' => [ 52 | 'tag' => 'message', 53 | 'message' => $this->successMessage, 54 | ], 55 | 'routes' => [], 56 | 'disposable' => false, 57 | 'error' => $invoice->error, 58 | ]; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Invoice/Domain/BackendInvoice/BackendInvoiceInterface.php: -------------------------------------------------------------------------------- 1 | name); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Invoice/Domain/BackendInvoice/LnbitsBackendInvoice.php: -------------------------------------------------------------------------------- 1 | options['api_endpoint'] . '/api/v1/payments'; 24 | 25 | $content = [ 26 | 'out' => false, 27 | 'amount' => $satsAmount, 28 | 'memo' => $memo, 29 | 'unhashed_description' => bin2hex($metadata), 30 | 'description_hash' => hash('sha256', $metadata), 31 | ]; 32 | 33 | $response = $this->httpApi->postRequestInvoice( 34 | $endpoint, 35 | body: json_encode($content, JSON_THROW_ON_ERROR), 36 | headers: [ 37 | 'Content-Type' => 'application/json', 38 | 'X-Api-Key' => $this->options['api_key'], 39 | ], 40 | ); 41 | 42 | if ($response === null) { 43 | return new InvoiceTransfer(status: 'ERROR', error: 'Backend "LnBits" unreachable'); 44 | } 45 | 46 | return InvoiceTransfer::fromArray($response); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Invoice/Domain/CallbackUrl/CallbackUrlInterface.php: -------------------------------------------------------------------------------- 1 | defaultLnAddress; 21 | } 22 | 23 | return sprintf('%s@%s', $username, $this->domain); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Invoice/Domain/CallbackUrl/LnAddressGeneratorInterface.php: -------------------------------------------------------------------------------- 1 | $headers 11 | * 12 | * @return array{ 13 | * checking_id: string, 14 | * payment_hash: string, 15 | * wallet_id: string, 16 | * amount: int, 17 | * fee: int, 18 | * bolt11: string, 19 | * status: string, 20 | * memo: string, 21 | * expiry: string|null, 22 | * webhook: string|null, 23 | * webhook_status: string|null, 24 | * preimage: string|null, 25 | * tag: string|null, 26 | * extension: string|null, 27 | * time: string, 28 | * created_at: string, 29 | * updated_at: string, 30 | * extra: array{ 31 | * wallet_fiat_currency: string, 32 | * wallet_fiat_amount: float, 33 | * wallet_fiat_rate: float, 34 | * wallet_btc_rate: float 35 | * } 36 | * }|null 37 | */ 38 | public function postRequestInvoice(string $uri, string $body, array $headers = []): ?array; 39 | } 40 | -------------------------------------------------------------------------------- /src/Invoice/Infrastructure/Controller/InvoiceController.php: -------------------------------------------------------------------------------- 1 | request->get('amount'); 32 | 33 | if ($amount === 0) { 34 | return new JsonResponse( 35 | $this->getFacade()->getCallbackUrl($username), 36 | ); 37 | } 38 | 39 | return new JsonResponse( 40 | $this->getFacade()->generateInvoice($username, $amount), 41 | ); 42 | } catch (Throwable $e) { 43 | dump($e); 44 | return new JsonResponse([ 45 | 'status' => 'ERROR', 46 | 'message' => $e->getMessage(), 47 | ]); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Invoice/Infrastructure/Http/HttpApi.php: -------------------------------------------------------------------------------- 1 | request('POST', $uri, [ 16 | 'headers' => $headers, 17 | 'body' => $body, 18 | ]); 19 | 20 | return json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Invoice/Infrastructure/Plugin/InvoiceRoutesPlugin.php: -------------------------------------------------------------------------------- 1 | router->configure(static function (Routes $routes): void { 21 | $routes->get('{username?}', InvoiceController::class); 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Invoice/InvoiceConfig.php: -------------------------------------------------------------------------------- 1 | get('callback-url', 'undefined:callback-url'); 18 | } 19 | 20 | public function getDefaultLnAddress(): string 21 | { 22 | return sprintf('%s@%s', $this->getReceiver(), $this->getDomain()); 23 | } 24 | 25 | /** 26 | * @return array 27 | */ 28 | public function getBackends(): array 29 | { 30 | /** @psalm-suppress MixedReturnTypeCoercion */ 31 | return (array)$this->get('backends'); // @phpstan-ignore-line 32 | } 33 | 34 | /** 35 | * @return array{ 36 | * api_endpoint: string, 37 | * api_key: string, 38 | * } 39 | */ 40 | public function getBackendOptionsFor(string $username): array 41 | { 42 | /** @var array{api_endpoint?: string, api_key?: string} $result */ 43 | $result = $this->getBackends()[$username] ?? []; 44 | 45 | if (!isset($result['api_endpoint'], $result['api_key'])) { 46 | throw new RuntimeException('Missing backend options for ' . $username); 47 | } 48 | 49 | return $result; 50 | } 51 | 52 | public function getSendableRange(): SendableRange 53 | { 54 | return $this->get('sendable-range', SendableRange::default()); 55 | } 56 | 57 | public function getDescriptionTemplate(): string 58 | { 59 | return (string)$this->get('description-template', 'Pay to %s'); 60 | } 61 | 62 | public function getSuccessMessage(): string 63 | { 64 | return (string)$this->get('success-message', 'Payment received!'); 65 | } 66 | 67 | public function getInvoiceMemo(): string 68 | { 69 | return (string)$this->get('invoice-memo', ''); 70 | } 71 | 72 | public function getDomain(): string 73 | { 74 | return (string)$this->get('domain', $_SERVER['HTTP_HOST'] ?? 'localhost'); 75 | } 76 | 77 | private function getReceiver(): string 78 | { 79 | return (string)$this->get('receiver', $_SERVER['REQUEST_URI'] ?? 'unknown-receiver'); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Invoice/InvoiceDependencyProvider.php: -------------------------------------------------------------------------------- 1 | set(self::HTTP_API, static fn () => new HttpApi()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Invoice/InvoiceFacade.php: -------------------------------------------------------------------------------- 1 | getFactory() 27 | ->createCallbackUrl($username) 28 | ->getCallbackUrl($username); 29 | } 30 | 31 | public function generateInvoice(string $username, int $milliSats): array 32 | { 33 | return $this->getFactory() 34 | ->createInvoiceGenerator($username) 35 | ->generateInvoice($milliSats); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Invoice/InvoiceFactory.php: -------------------------------------------------------------------------------- 1 | validateUserExists($username); 26 | } 27 | 28 | return new CallbackUrl( 29 | $this->getConfig()->getSendableRange(), 30 | $this->createLnAddressGenerator(), 31 | $this->getConfig()->getCallback(), 32 | $this->getConfig()->getDescriptionTemplate(), 33 | ); 34 | } 35 | 36 | public function createInvoiceGenerator(string $username): InvoiceGenerator 37 | { 38 | return new InvoiceGenerator( 39 | $this->getBackendForUser($username), 40 | $this->getConfig()->getSendableRange(), 41 | $this->getConfig()->getDefaultLnAddress(), 42 | $this->getConfig()->getDescriptionTemplate(), 43 | $this->getConfig()->getSuccessMessage(), 44 | $this->getConfig()->getInvoiceMemo(), 45 | ); 46 | } 47 | 48 | private function createLnAddressGenerator(): LnAddressGeneratorInterface 49 | { 50 | return new LnAddressGenerator( 51 | $this->getConfig()->getDefaultLnAddress(), 52 | $this->getConfig()->getDomain(), 53 | ); 54 | } 55 | 56 | private function getBackendForUser(string $username): BackendInvoiceInterface 57 | { 58 | return new LnbitsBackendInvoice( 59 | $this->getHttpApi(), 60 | $this->getConfig()->getBackendOptionsFor($username), 61 | ); 62 | } 63 | 64 | private function getHttpApi(): HttpApiInterface 65 | { 66 | return $this->getProvidedDependency(InvoiceDependencyProvider::HTTP_API); 67 | } 68 | 69 | private function validateUserExists(string $username): void 70 | { 71 | $this->getConfig()->getBackendOptionsFor($username); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Shared/Transfer/InvoiceExtraTransfer.php: -------------------------------------------------------------------------------- 1 | = $this->min 37 | && $amount <= $this->max; 38 | } 39 | 40 | public function min(): int 41 | { 42 | return $this->min; 43 | } 44 | 45 | public function max(): int 46 | { 47 | return $this->max; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/Feature/Fake/FakeHttpApi.php: -------------------------------------------------------------------------------- 1 | '8efa8998bca6e298ae63dc7425c8a34b5373511d88a70f6f29ba98f833a63f04', 15 | 'payment_hash' => '8efa8998bca6e298ae63dc7425c8a34b5373511d88a70f6f29ba98f833a63f04', 16 | 'wallet_id' => 'd73709a1301146b7ae0c748e3a0ecef2', 17 | 'amount' => 1000000, 18 | 'fee' => 0, 19 | 'bolt11' => 'lnbc10u1p5r9lmwpp53magnx9u5m3f3tnrm36ztj9rfdfhx5ga3zns7mefh2v0svax8uzqcqzyssp54twf429a8cvz6tflw5lt705gfnvuykhdeewey009tugjcuamt38q9q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgqdqqmqz9gxqrrssrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glclll4ttz7sp6kpvqqqqlgqqqqqeqqjq0uu89sejjllry5ye43x0v42jn48c6alfc9mfnjla2u6kmwy444pzrjmtu25nk2shshuh2mrqtehygmzya9xg89ppszuuhd9296vvcxspkpwc68', 20 | 'status' => 'pending', 21 | 'memo' => '', 22 | 'expiry' => '2025-05-25T12:30:54', 23 | 'webhook' => null, 24 | 'webhook_status' => null, 25 | 'preimage' => null, 26 | 'tag' => null, 27 | 'extension' => null, 28 | 'time' => '2025-05-25T11:30:54.433442+00:00', 29 | 'created_at' => '2025-05-25T11:30:54.433447+00:00', 30 | 'updated_at' => '2025-05-25T11:30:54.433449+00:00', 31 | 'extra' => [ 32 | 'wallet_fiat_currency' => 'USD', 33 | 'wallet_fiat_amount' => 1.071, 34 | 'wallet_fiat_rate' => 933.31818946794, 35 | 'wallet_btc_rate' => 107144.595625, 36 | ], 37 | ]; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Feature/InvoiceFacadeTest.php: -------------------------------------------------------------------------------- 1 | facade = new InvoiceFacade(); 24 | } 25 | 26 | public function test_get_get_callback_url(): void 27 | { 28 | $this->bootstrapGacela(); 29 | $this->mockLnPaymentRequest(); 30 | 31 | $json = $this->facade->getCallbackUrl('bob'); 32 | 33 | self::assertEquals([ 34 | 'callback' => 'https://callback.url/receiver', 35 | 'maxSendable' => 10_000, 36 | 'minSendable' => 1_000, 37 | 'metadata' => '[["text/plain","Pay to bob@domain.com"],["text/identifier","bob@domain.com"]]', 38 | 'tag' => 'payRequest', 39 | 'commentAllowed' => false, 40 | ], $json); 41 | } 42 | 43 | public function test_ln_bits_feature(): void 44 | { 45 | $this->bootstrapGacela(); 46 | $this->mockLnPaymentRequest(); 47 | 48 | $json = $this->facade->generateInvoice('alice', 2_000); 49 | 50 | self::assertEquals([ 51 | 'bolt11' => 'lnbc10u1p5r9lmwpp53magnx9u5m3f3tnrm36ztj9rfdfhx5ga3zns7mefh2v0svax8uzqcqzyssp54twf429a8cvz6tflw5lt705gfnvuykhdeewey009tugjcuamt38q9q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgqdqqmqz9gxqrrssrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glclll4ttz7sp6kpvqqqqlgqqqqqeqqjq0uu89sejjllry5ye43x0v42jn48c6alfc9mfnjla2u6kmwy444pzrjmtu25nk2shshuh2mrqtehygmzya9xg89ppszuuhd9296vvcxspkpwc68', 52 | 'status' => 'pending', 53 | 'successAction' => [ 54 | 'tag' => 'message', 55 | 'message' => 'Payment received!', 56 | ], 57 | 'routes' => [], 58 | 'disposable' => false, 59 | 'memo' => '', 60 | 'error' => null, 61 | ], $json); 62 | } 63 | 64 | private function bootstrapGacela(): void 65 | { 66 | Gacela::bootstrap(__DIR__, static function (GacelaConfig $config): void { 67 | $config->resetInMemoryCache(); 68 | $config->addAppConfigKeyValues( 69 | (new LightningConfig()) // @phpstan-ignore-line 70 | ->setCallbackUrl('https://callback.url/receiver') 71 | ->setDomain('domain.com') 72 | ->setReceiver('receiver') 73 | ->setSendableRange(1_000, 10_000) 74 | ->addBackendsFile(__DIR__ . DIRECTORY_SEPARATOR . 'nostr.json') 75 | ->jsonSerialize(), 76 | ); 77 | }); 78 | } 79 | 80 | private function mockLnPaymentRequest(): void 81 | { 82 | Gacela::overrideExistingResolvedClass( 83 | InvoiceDependencyProvider::class, 84 | new class() extends AbstractProvider { 85 | public function provideModuleDependencies(Container $container): void 86 | { 87 | $container->set(InvoiceDependencyProvider::HTTP_API, static fn () => new FakeHttpApi()); 88 | } 89 | }, 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tests/Feature/nostr.json: -------------------------------------------------------------------------------- 1 | { 2 | "bob": { 3 | "type": "lnbits", 4 | "api_key": "abc...123", 5 | "api_endpoint": "http://localhost:5000" 6 | }, 7 | "alice": { 8 | "type": "lnbits", 9 | "api_key": "def...456", 10 | "api_endpoint": "http://localhost:5000" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/Unit/Config/LightningConfigTest.php: -------------------------------------------------------------------------------- 1 | jsonSerialize()); 18 | } 19 | 20 | public function test_domain_with_scheme(): void 21 | { 22 | $config = (new LightningConfig()) 23 | ->setDomain('https://your-domain.com'); 24 | 25 | self::assertSame([ 26 | 'domain' => 'your-domain.com', 27 | ], $config->jsonSerialize()); 28 | } 29 | 30 | public function test_domain_without_scheme(): void 31 | { 32 | $config = (new LightningConfig()) 33 | ->setDomain('your-domain.com'); 34 | 35 | self::assertSame([ 36 | 'domain' => 'your-domain.com', 37 | ], $config->jsonSerialize()); 38 | } 39 | 40 | public function test_receiver(): void 41 | { 42 | $config = (new LightningConfig()) 43 | ->setReceiver('custom-receiver'); 44 | 45 | self::assertSame([ 46 | 'receiver' => 'custom-receiver', 47 | ], $config->jsonSerialize()); 48 | } 49 | 50 | public function test_sendable_range(): void 51 | { 52 | $config = (new LightningConfig()) 53 | ->setSendableRange(1_000, 5_000); 54 | 55 | self::assertEquals([ 56 | 'sendable-range' => SendableRange::withMinMax(1_000, 5_000), 57 | ], $config->jsonSerialize()); 58 | } 59 | 60 | public function test_description_and_success_message(): void 61 | { 62 | $config = (new LightningConfig()) 63 | ->setDescriptionTemplate('Pay to %s on example') 64 | ->setSuccessMessage('Thanks!'); 65 | 66 | self::assertSame([ 67 | 'description-template' => 'Pay to %s on example', 68 | 'success-message' => 'Thanks!', 69 | ], $config->jsonSerialize()); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/Unit/Invoice/Domain/BackendInvoice/LnbitsBackendInvoiceTest.php: -------------------------------------------------------------------------------- 1 | createStub(HttpApiInterface::class); 17 | $httpApi->method('postRequestInvoice')->willReturn(null); 18 | 19 | $invoice = new LnbitsBackendInvoice($httpApi, [ 20 | 'api_endpoint' => 'endpoint', 21 | 'api_key' => 'key', 22 | ]); 23 | 24 | $actual = $invoice->requestInvoice(100, '', ''); 25 | $expected = new InvoiceTransfer(error: 'Backend "LnBits" unreachable', status: 'ERROR'); 26 | 27 | self::assertEquals($expected, $actual); 28 | } 29 | 30 | public function test_request_invoice_when_api_returns_payment_request(): void 31 | { 32 | $httpApi = $this->createStub(HttpApiInterface::class); 33 | $httpApi->method('postRequestInvoice')->willReturn([ 34 | 'bolt11' => 'ln1234567890', 35 | 'status' => 'OK', 36 | ]); 37 | 38 | $invoice = new LnbitsBackendInvoice($httpApi, [ 39 | 'api_endpoint' => 'endpoint', 40 | 'api_key' => 'key', 41 | ]); 42 | 43 | $actual = $invoice->requestInvoice(100, '', ''); 44 | $expected = new InvoiceTransfer(bolt11: 'ln1234567890'); 45 | 46 | self::assertEquals($expected, $actual); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/Unit/Invoice/Domain/CallbackUrl/CallbackUrlTest.php: -------------------------------------------------------------------------------- 1 | createStub(BackendInvoiceInterface::class); 20 | $invoiceFacade->method('requestInvoice')->willReturn(new InvoiceTransfer()); 21 | 22 | $lnAddressGenerator = $this->createStub(LnAddressGeneratorInterface::class); 23 | $lnAddressGenerator->method('generate')->willReturn('ln@address'); 24 | 25 | $callbackUrl = new CallbackUrl( 26 | SendableRange::withMinMax(1_000, 5_000), 27 | $lnAddressGenerator, 28 | 'https://domain/receiver', 29 | 'Pay to %s', 30 | ); 31 | 32 | self::assertEquals([ 33 | 'callback' => 'https://domain/receiver', 34 | 'minSendable' => 1_000, 35 | 'maxSendable' => 5_000, 36 | 'metadata' => json_encode([ 37 | ['text/plain', 'Pay to ln@address'], 38 | ['text/identifier', 'ln@address'], 39 | ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), 40 | 'tag' => 'payRequest', 41 | 'commentAllowed' => false, 42 | ], $callbackUrl->getCallbackUrl('username')); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Unit/Invoice/Domain/LnAddress/InvoiceGeneratorTest.php: -------------------------------------------------------------------------------- 1 | createStub(BackendInvoiceInterface::class); 18 | $invoiceFacade->method('requestInvoice')->willReturn(new InvoiceTransfer()); 19 | 20 | $invoice = new InvoiceGenerator( 21 | $invoiceFacade, 22 | SendableRange::withMinMax(1_000, 3_000), 23 | 'ln@address', 24 | 'Pay to %s', 25 | 'Payment received!', 26 | '', 27 | ); 28 | $actual = $invoice->generateInvoice(100); 29 | 30 | self::assertSame([ 31 | 'status' => 'ERROR', 32 | 'reason' => 'Amount is not between minimum and maximum sendable amount', 33 | ], $actual); 34 | } 35 | 36 | public function test_unknown_backend(): void 37 | { 38 | $invoiceFacade = $this->createStub(BackendInvoiceInterface::class); 39 | $invoiceFacade->method('requestInvoice') 40 | ->willReturn(new InvoiceTransfer(error: 'some reason', status: 'ERROR')); 41 | 42 | $invoice = new InvoiceGenerator( 43 | $invoiceFacade, 44 | SendableRange::withMinMax(1_000, 3_000), 45 | 'ln@address', 46 | 'Pay to %s', 47 | 'Payment received!', 48 | '', 49 | ); 50 | $actual = $invoice->generateInvoice(2_000); 51 | 52 | self::assertEquals([ 53 | 'bolt11' => '', 54 | 'status' => 'ERROR', 55 | 'memo' => '', 56 | 'successAction' => [ 57 | 'tag' => 'message', 58 | 'message' => 'Payment received!', 59 | ], 60 | 'routes' => [], 61 | 'disposable' => false, 62 | 'error' => 'some reason', 63 | ], $actual); 64 | } 65 | 66 | public function test_successful_payment_request_with_amount(): void 67 | { 68 | $invoiceFacade = $this->createStub(BackendInvoiceInterface::class); 69 | $invoiceFacade->method('requestInvoice') 70 | ->willReturn(new InvoiceTransfer(bolt11: 'ln123456789', memo: 'Custom memo')); 71 | 72 | $invoice = new InvoiceGenerator( 73 | $invoiceFacade, 74 | SendableRange::withMinMax(1_000, 3_000), 75 | 'ln@address', 76 | 'Pay to %s', 77 | 'Payment received!', 78 | '', 79 | ); 80 | $actual = $invoice->generateInvoice(2_000); 81 | 82 | self::assertEquals([ 83 | 'bolt11' => 'ln123456789', 84 | 'status' => 'OK', 85 | 'memo' => 'Custom memo', 86 | 'successAction' => [ 87 | 'tag' => 'message', 88 | 'message' => 'Payment received!', 89 | ], 90 | 'routes' => [], 91 | 'disposable' => false, 92 | 'error' => null, 93 | ], $actual); 94 | } 95 | 96 | public function test_passes_memo_to_backend(): void 97 | { 98 | $backend = $this->createMock(BackendInvoiceInterface::class); 99 | $backend->expects(self::once()) 100 | ->method('requestInvoice') 101 | ->with(2, $this->anything(), 'Custom memo') 102 | ->willReturn(new InvoiceTransfer()); 103 | 104 | $invoice = new InvoiceGenerator( 105 | $backend, 106 | SendableRange::withMinMax(1_000, 3_000), 107 | 'ln@address', 108 | 'Pay to %s', 109 | 'Payment received!', 110 | 'Custom memo', 111 | ); 112 | 113 | $invoice->generateInvoice(2_000); 114 | } 115 | } 116 | --------------------------------------------------------------------------------