commit d7911791255ddc69f9a9a6fb076e6c0bb7b61f58 Author: Pierre-Marie Padiou Date: Fri Mar 8 18:44:24 2024 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6d3486 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +.gradle +build/ +.cxx + +# this file is local to the dev environment and must not be pushed! +local.properties + +# Ignore specific Mac files +.DS_Store +# Non relevant Xcode files +xcuserdata/ +# HPROF +*.hprof + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Cache of project +.gradletasknamecache + +*.class +*.log + +# sbt specific +.cache/ +.history/ +.lib/ +dist/* +target/ +lib_managed/ +src_managed/ +project/boot/ +project/plugins/project/ + +# Scala-IDE specific +.scala_dependencies +.worksheet + +.idea +*.iml +target/ +project/target +DeleteMe*.scala diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1f0fdf1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,152 @@ +# Contributing to the lightning-kmp project + +ACINQ welcomes contributions in the form of peer review, testing and patches. +This document explains the practical process and guidelines for contributing. + +While developing a Lightning implementation is an exciting project that spans many domains +(cryptography, peer-to-peer networking, databases, etc), contributors must keep in mind that this +represents real money and introducing bugs or security vulnerabilities can have far more dire +consequences than in typical projects. In the world of cryptocurrencies, even the smallest bug in +the wrong area can cost users a significant amount of money. + +If you're looking for somewhere to start contributing, check out the [good first issue](https://github.com/ACINQ/lightning-kmp/issues?q=is%3Aopen+is%3Aissue+label%3A"good+first+issue") list. + +Another way to start contributing is by adding tests or improving them. +This will help you understand the different parts of the codebase and how they work together. + +## Communicating + +We recommend using our Gitter [developers channel](https://gitter.im/ACINQ/developers). +Introducing yourself and explaining what you'd like to work on is always a good idea: you will get +some pointers and feedback from experienced contributors. It will also ensure that you're not +duplicating work that someone else is doing. + +We use Github issues only for, well, issues (mostly bugs that need to be investigated). +You can also use Github issues for [feature requests](https://github.com/ACINQ/lightning-kmp/issues?q=is%3Aissue+label%3A"feature+request"). + +## Recommended Reading + +- [Bitcoin Whitepaper](https://bitcoin.org/bitcoin.pdf) +- [Lightning Network Whitepaper](https://lightning.network/lightning-network-paper.pdf) +- [Deployable Lightning](https://github.com/ElementsProject/lightning/raw/master/doc/deployable-lightning.pdf) +- [Understanding the Lightning Network](https://bitcoinmagazine.com/articles/understanding-the-lightning-network-part-building-a-bidirectional-payment-channel-1464710791) +- [Lightning Network Specification](https://github.com/lightningnetwork/lightning-rfc) +- [High Level Lightning Network Specification](https://medium.com/@rusty_lightning/the-bitcoin-lightning-spec-part-1-8-a7720fb1b4da) + +## Recommended Skillset + +lightning-kmp uses [Kotlin Multiplatform](https://kotlinlang.org/docs/reference/multiplatform.html) with [Kotlin Coroutines](https://kotlinlang.org/docs/reference/coroutines-overview.html). +Good understanding of these technologies is required to contribute. +There are a lot of good resources online to learn about them. + +## Contributor Workflow + +To contribute a patch, the workflow is as follows: + +1. [Fork repository](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) (only the first time) +2. Create a topic branch +3. Add commits +4. Open a pull request + +### Pull Request Philosophy + +Pull requests should always be focused. For example, a pull request could add a feature, fix a bug, +or refactor code; but not a mixture. +Please also avoid super pull requests which attempt to do too much, are overly large, or overly +complex as this makes review difficult. + +You should try your best to make reviewers' lives as easy as possible: a lot more time will be +spent reading your code than the time you spent writing it. +The quicker your changes are merged to master, the less time you will need to spend rebasing and +otherwise trying to keep up with the master branch. + +Pull request should always include a clean, detailed description of what they fix/improve, why, +and how. +Even if you think that it is obvious, don't be shy and add explicit details and explanations. + +When fixing a bug, please start by adding a failing test that reproduces the issue. +Create a first commit containing that test without the fix: this makes it easy to verify that the +test corcrectly failed. You can then fix the bug in additional commits. + +When adding a new feature, thought must be given to the long term technical debt and maintenance +that feature may require after inclusion. Before proposing a new feature that will require +maintenance, please consider if you are willing to maintain it (including bug fixing). + +When addressing pull request comments, we recommend using [fixup commits](https://robots.thoughtbot.com/autosquashing-git-commits). +The reason for this is two fold: it makes it easier for the reviewer to see what changes have been +made between versions (since Github doesn't easily show prior versions) and it makes it easier on +the PR author as they can set it to auto-squash the fixup commits on rebase. + +It's recommended to take great care in writing tests and ensuring the entire test suite has a +stable successful outcome; lightning-kmp uses continuous integration techniques and having a stable build +helps the reviewers with their job. + +Contributors should follow the default Kotlin coding style guide. If you use IntelliJ: + +- File > Settings > Editor > Code Style + - In the "Formatter Control" tab, check "Enable formatter markers in comments" +- File > Settings > Editor > Code Style > Kotlin + - select "Set from..." and choose "Kotline style guide" + - set "Hard wrap at" to 240 + +### Signed Commits + +We ask contributors to sign their commits. +You can find setup instructions [here](https://help.github.com/en/github/authenticating-to-github/signing-commits). + +### Commit Message + +lightning-kmp keeps a clean commit history on the master branch with well-formed commit messages. + +Here is a model Git commit message: + +```text +Short (50 chars or less) summary of changes + +More detailed explanatory text, if necessary. Wrap it to about 72 +characters or so. In some contexts, the first line is treated as the +subject of an email and the rest of the text as the body. The blank +line separating the summary from the body is critical (unless you omit +the body entirely); tools like rebase can get confused if you run the +two together. + +Write your commit message in the present tense: "Fix bug" and not +"Fixed bug". This convention matches up with commit messages generated +by commands like git merge and git revert. + +Further paragraphs come after blank lines. + +- Bullet points are okay, too +- Typically a hyphen or asterisk is used for the bullet, preceded by a + single space, with blank lines in between, but conventions vary here +- Use a hanging indent +``` + +### Dependencies + +We try to minimize our dependencies (libraries and tools). Introducing new dependencies increases +package size, attack surface and cognitive overhead. + +If your contribution is adding a new dependency, please detail: + +- why you need it +- why you chose this specific library/tool (a thorough analysis of alternatives will be + appreciated) + +Contributions that add new dependencies may take longer to approve because a detailed audit of the +dependency may be required. + +### IntelliJ Tips + +If you're using [IntelliJ](https://www.jetbrains.com/idea/), here are some useful commands: + +- Ctrl+Alt+L: format file (ensures consistency in the codebase) +- Ctrl+Alt+o: optimize imports (removes unused imports) + +### Contribution Checklist + +- The code being submitted is accompanied by tests which exercise both the positive and negative + (error paths) conditions (if applicable) +- The code being submitted is correctly formatted +- The code being submitted has a clean, easy-to-follow commit history +- All commits are signed diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d565dcd --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018 ACINQ SAS + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e947f2c --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# phoenixd \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..37d2081 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,119 @@ +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTargetWithHostTests + +buildscript { + dependencies { + classpath("app.cash.sqldelight:gradle-plugin:2.0.1") + } + repositories { + google() + mavenCentral() + } +} + +plugins { + kotlin("multiplatform") version "1.9.23" + kotlin("plugin.serialization") version "1.9.23" + id("app.cash.sqldelight") version "2.0.1" +} + +allprojects { + group = "fr.acinq.lightning" + version = "0.1-SNAPSHOT" + + repositories { + // using the local maven repository with Kotlin Multi Platform can lead to build errors that are hard to diagnose. + // uncomment this only if you need to experiment with snapshot dependencies that have not yet be published. + mavenLocal() + maven("https://oss.sonatype.org/content/repositories/snapshots") + mavenCentral() + google() + } +} + +kotlin { + jvm() + + fun KotlinNativeTargetWithHostTests.phoenixBinaries() { + binaries { + executable("phoenixd") { + entryPoint = "fr.acinq.lightning.bin.main" + optimized = false // without this, release mode throws 'Index 0 out of bounds for length 0' in StaticInitializersOptimization.kt + } + executable("phoenix-cli") { + entryPoint = "fr.acinq.lightning.cli.main" + optimized = false // without this, release mode throws 'Index 0 out of bounds for length 0' in StaticInitializersOptimization.kt + } + } + } + + val currentOs = org.gradle.internal.os.OperatingSystem.current() + if (currentOs.isLinux) { + linuxX64 { + phoenixBinaries() + } + } + + if (currentOs.isMacOsX) { + macosX64 { + phoenixBinaries() + } + } + + val ktorVersion = "2.3.8" + fun ktor(module: String) = "io.ktor:ktor-$module:$ktorVersion" + + sourceSets { + commonMain { + dependencies { + implementation("fr.acinq.lightning:lightning-kmp:1.6.2-SNAPSHOT") + // ktor serialization + implementation(ktor("serialization-kotlinx-json")) + // ktor server + implementation(ktor("server-core")) + implementation(ktor("server-content-negotiation")) + implementation(ktor("server-cio")) + implementation(ktor("server-websockets")) + implementation(ktor("server-auth")) + implementation(ktor("server-status-pages")) // exception handling + // ktor client (needed for webhook) + implementation(ktor("client-core")) + implementation(ktor("client-content-negotiation")) + implementation(ktor("client-cio")) + implementation(ktor("client-auth")) + implementation(ktor("client-json")) + + implementation("com.squareup.okio:okio:3.8.0") + implementation("com.github.ajalt.clikt:clikt:4.2.2") + implementation("app.cash.sqldelight:coroutines-extensions:2.0.1") + } + } + jvmMain { + dependencies { + implementation("app.cash.sqldelight:sqlite-driver:2.0.1") + } + } + nativeMain { + dependencies { + implementation("app.cash.sqldelight:native-driver:2.0.1") + } + } + } +} + +// forward std input when app is run via gradle (otherwise keyboard input will return EOF) +tasks.withType { + standardInput = System.`in` +} + +sqldelight { + databases { + create("ChannelsDatabase") { + packageName.set("fr.acinq.phoenix.db") + srcDirs.from("src/commonMain/sqldelight/channelsdb") + } + create("PaymentsDatabase") { + packageName.set("fr.acinq.phoenix.db") + srcDirs.from("src/commonMain/sqldelight/paymentsdb") + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..c069fca --- /dev/null +++ b/gradle.properties @@ -0,0 +1,9 @@ +# gradle +org.gradle.jvmargs=-Xmx1536m +org.gradle.parallel=true +# kotlin +kotlin.code.style=official +kotlin.incremental.multiplatform=true +kotlin.mpp.stability.nowarn=true +kotlin.mpp.enableCInteropCommonization=true +kotlin.native.ignoreDisabledTargets=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7f93135 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3fa8f86 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..b5fd019 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,9 @@ +rootProject.name = "phoenixd" + +pluginManagement { + repositories { + gradlePluginPortal() + maven("https://dl.bintray.com/kotlin/kotlin-eap") + } +} + diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt new file mode 100644 index 0000000..67fcfe2 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/Api.kt @@ -0,0 +1,185 @@ +package fr.acinq.lightning.bin + +import fr.acinq.bitcoin.Bitcoin +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Script +import fr.acinq.bitcoin.utils.Either +import fr.acinq.bitcoin.utils.toEither +import fr.acinq.lightning.Lightning.randomBytes32 +import fr.acinq.lightning.NodeParams +import fr.acinq.lightning.bin.json.ApiType.* +import fr.acinq.lightning.blockchain.fee.FeeratePerByte +import fr.acinq.lightning.blockchain.fee.FeeratePerKw +import fr.acinq.lightning.channel.ChannelCommand +import fr.acinq.lightning.channel.states.ChannelStateWithCommitments +import fr.acinq.lightning.channel.states.ClosingFeerates +import fr.acinq.lightning.io.Peer +import fr.acinq.lightning.io.WrappedChannelCommand +import fr.acinq.lightning.payment.Bolt11Invoice +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.utils.sum +import fr.acinq.lightning.utils.toByteVector +import fr.acinq.lightning.utils.toMilliSatoshi +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.engine.* +import io.ktor.server.plugins.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.plugins.statuspages.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.websocket.* +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +class Api(private val nodeParams: NodeParams, private val peer: Peer, private val eventsFlow: SharedFlow, private val password: String, private val webhookUrl: Url?) { + + fun Application.module() { + + val json = Json { + prettyPrint = true + isLenient = true + serializersModule = fr.acinq.lightning.json.JsonSerializers.json.serializersModule + } + + install(ContentNegotiation) { + json(json) + } + install(WebSockets) { + contentConverter = KotlinxWebsocketSerializationConverter(json) + timeoutMillis = 10_000 + pingPeriodMillis = 10_000 + } + install(StatusPages) { + exception { call, cause -> + call.respondText(text = cause.message ?: "", status = defaultExceptionStatusCode(cause) ?: HttpStatusCode.InternalServerError) + } + } + install(Authentication) { + basic { + validate { credentials -> + if (credentials.password == password) { + UserIdPrincipal(credentials.name) + } else { + null + } + } + } + } + + routing { + authenticate { + get("getinfo") { + val info = NodeInfo( + nodeId = nodeParams.nodeId, + channels = peer.channels.values.map { Channel.from(it) } + ) + call.respond(info) + } + get("getbalance") { + val balance = peer.channels.values + .filterIsInstance() + .map { it.commitments.active.first().availableBalanceForSend(it.commitments.params, it.commitments.changes) } + .sum().truncateToSatoshi() + call.respond(Balance(balance, nodeParams.feeCredit.value)) + } + get("listchannels") { + call.respond(peer.channels.values.toList()) + } + post("createinvoice") { + val formParameters = call.receiveParameters() + val amount = formParameters.getLong("amountSat").sat + val description = formParameters.getString("description") + val invoice = peer.createInvoice(randomBytes32(), amount.toMilliSatoshi(), Either.Left(description)) + call.respond(GeneratedInvoice(invoice.amount?.truncateToSatoshi(), invoice.paymentHash, serialized = invoice.write())) + } + post("payinvoice") { + val formParameters = call.receiveParameters() + val overrideAmount = formParameters["amountSat"]?.let { it.toLongOrNull() ?: invalidType("amountSat", "integer") }?.sat?.toMilliSatoshi() + val invoice = formParameters.getInvoice("invoice") + val amount = (overrideAmount ?: invoice.amount) ?: missing("amountSat") + when (val event = peer.sendLightning(amount, invoice)) { + is fr.acinq.lightning.io.PaymentSent -> call.respond(PaymentSent(event)) + is fr.acinq.lightning.io.PaymentNotSent -> call.respond(PaymentFailed(event)) + } + } + post("sendtoaddress") { + val res = kotlin.runCatching { + val formParameters = call.receiveParameters() + val amount = formParameters.getLong("amountSat").sat + val scriptPubKey = formParameters.getAddressAndConvertToScript("address") + val feerate = FeeratePerKw(FeeratePerByte(formParameters.getLong("feerateSatByte").sat)) + peer.spliceOut(amount, scriptPubKey, feerate) + }.toEither() + when (res) { + is Either.Right -> when (val r = res.value) { + is ChannelCommand.Commitment.Splice.Response.Created -> call.respondText(r.fundingTxId.toString()) + is ChannelCommand.Commitment.Splice.Response.Failure -> call.respondText(r.toString()) + else -> call.respondText("no channel available") + } + is Either.Left -> call.respondText(res.value.message.toString()) + } + } + post("closechannel") { + val formParameters = call.receiveParameters() + val channelId = formParameters.getByteVector32("channelId") + val scriptPubKey = formParameters.getAddressAndConvertToScript("address") + val feerate = FeeratePerKw(FeeratePerByte(formParameters.getLong("feerateSatByte").sat)) + peer.send(WrappedChannelCommand(channelId, ChannelCommand.Close.MutualClose(scriptPubKey, ClosingFeerates(feerate)))) + call.respondText("ok") + } + webSocket("/websocket") { + try { + eventsFlow.collect { sendSerialized(it) } + } catch (e: Throwable) { + println("onError ${closeReason.await()}") + } + } + } + } + + webhookUrl?.let { url -> + val client = HttpClient(io.ktor.client.engine.cio.CIO) { + install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) { + json(json = Json { + prettyPrint = true + isLenient = true + }) + } + } + launch { + eventsFlow.collect { event -> + client.post(url) { + contentType(ContentType.Application.Json) + setBody(event) + } + } + } + } + } + + private fun missing(argName: String): Nothing = throw MissingRequestParameterException(argName) + + private fun invalidType(argName: String, typeName: String): Nothing = throw ParameterConversionException(argName, typeName) + + private fun Parameters.getString(argName: String): String = (this[argName] ?: missing(argName)) + + private fun Parameters.getByteVector32(argName: String): ByteVector32 = getString(argName).let { hex -> kotlin.runCatching { ByteVector32.fromValidHex(hex) }.getOrNull() ?: invalidType(argName, "hex32") } + + private fun Parameters.getAddressAndConvertToScript(argName: String): ByteVector = Script.write(Bitcoin.addressToPublicKeyScript(nodeParams.chainHash, getString(argName)).right ?: error("invalid address")).toByteVector() + + private fun Parameters.getInvoice(argName: String): Bolt11Invoice = getString(argName).let { invoice -> Bolt11Invoice.read(invoice).getOrElse { invalidType(argName, "bolt11invoice") } } + + private fun Parameters.getLong(argName: String): Long = ((this[argName] ?: missing(argName)).toLongOrNull()) ?: invalidType(argName, "integer") + +} + + diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/Expects.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/Expects.kt new file mode 100644 index 0000000..3a129de --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/Expects.kt @@ -0,0 +1,9 @@ +package fr.acinq.lightning.bin + +import app.cash.sqldelight.db.SqlDriver +import okio.Path + +expect val homeDirectory: Path + +expect fun createAppDbDriver(dir: Path): SqlDriver +expect fun createPaymentsDbDriver(dir: Path): SqlDriver diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/InMemoryPaymentsDb.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/InMemoryPaymentsDb.kt new file mode 100644 index 0000000..1b03567 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/InMemoryPaymentsDb.kt @@ -0,0 +1,135 @@ +package fr.acinq.lightning.bin + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Crypto +import fr.acinq.bitcoin.TxId +import fr.acinq.bitcoin.utils.Either +import fr.acinq.lightning.channel.ChannelException +import fr.acinq.lightning.db.* +import fr.acinq.lightning.payment.FinalFailure +import fr.acinq.lightning.payment.OutgoingPaymentFailure +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.toByteVector32 +import fr.acinq.lightning.wire.FailureMessage + +class InMemoryPaymentsDb : PaymentsDb { + private val incoming = mutableMapOf() + private val outgoing = mutableMapOf() + private val outgoingParts = mutableMapOf>() + override suspend fun setLocked(txId: TxId) {} + + override suspend fun addIncomingPayment(preimage: ByteVector32, origin: IncomingPayment.Origin, createdAt: Long): IncomingPayment { + val paymentHash = Crypto.sha256(preimage).toByteVector32() + require(!incoming.contains(paymentHash)) { "an incoming payment for $paymentHash already exists" } + val incomingPayment = IncomingPayment(preimage, origin, null, createdAt) + incoming[paymentHash] = incomingPayment + return incomingPayment + } + + override suspend fun getIncomingPayment(paymentHash: ByteVector32): IncomingPayment? = incoming[paymentHash] + + override suspend fun receivePayment(paymentHash: ByteVector32, receivedWith: List, receivedAt: Long) { + when (val payment = incoming[paymentHash]) { + null -> Unit // no-op + else -> incoming[paymentHash] = run { + payment.copy( + received = IncomingPayment.Received( + receivedWith = (payment.received?.receivedWith ?: emptySet()) + receivedWith, + receivedAt = receivedAt + ) + ) + } + } + } + + fun listIncomingPayments(count: Int, skip: Int): List = + incoming.values + .asSequence() + .sortedByDescending { it.createdAt } + .drop(skip) + .take(count) + .toList() + + override suspend fun listExpiredPayments(fromCreatedAt: Long, toCreatedAt: Long): List = + incoming.values + .asSequence() + .filter { it.createdAt in fromCreatedAt until toCreatedAt } + .filter { it.isExpired() } + .filter { it.received == null } + .sortedByDescending { it.createdAt } + .toList() + + override suspend fun removeIncomingPayment(paymentHash: ByteVector32): Boolean { + val payment = getIncomingPayment(paymentHash) + return when (payment?.received) { + null -> incoming.remove(paymentHash) != null + else -> false // do nothing if payment already partially paid + } + } + + override suspend fun addOutgoingPayment(outgoingPayment: OutgoingPayment) { + require(!outgoing.contains(outgoingPayment.id)) { "an outgoing payment with id=${outgoingPayment.id} already exists" } + when (outgoingPayment) { + is LightningOutgoingPayment -> { + outgoingPayment.parts.forEach { require(!outgoingParts.contains(it.id)) { "an outgoing payment part with id=${it.id} already exists" } } + outgoing[outgoingPayment.id] = outgoingPayment.copy(parts = listOf()) + outgoingPayment.parts.forEach { outgoingParts[it.id] = Pair(outgoingPayment.id, it) } + } + is OnChainOutgoingPayment -> {} // we don't persist on-chain payments + } + } + + override suspend fun getLightningOutgoingPayment(id: UUID): LightningOutgoingPayment? { + return outgoing[id]?.let { payment -> + val parts = outgoingParts.values.filter { it.first == payment.id }.map { it.second } + return when (payment.status) { + is LightningOutgoingPayment.Status.Completed.Succeeded -> payment.copy(parts = parts.filter { it.status is LightningOutgoingPayment.Part.Status.Succeeded }) + else -> payment.copy(parts = parts) + } + } + } + + override suspend fun completeOutgoingPaymentOffchain(id: UUID, preimage: ByteVector32, completedAt: Long) { + require(outgoing.contains(id)) { "outgoing payment with id=$id doesn't exist" } + val payment = outgoing[id]!! + outgoing[id] = payment.copy(status = LightningOutgoingPayment.Status.Completed.Succeeded.OffChain(preimage = preimage, completedAt = completedAt)) + } + + override suspend fun completeOutgoingPaymentOffchain(id: UUID, finalFailure: FinalFailure, completedAt: Long) { + require(outgoing.contains(id)) { "outgoing payment with id=$id doesn't exist" } + val payment = outgoing[id]!! + outgoing[id] = payment.copy(status = LightningOutgoingPayment.Status.Completed.Failed(reason = finalFailure, completedAt = completedAt)) + } + + override suspend fun addOutgoingLightningParts(parentId: UUID, parts: List) { + require(outgoing.contains(parentId)) { "parent outgoing payment with id=$parentId doesn't exist" } + parts.forEach { require(!outgoingParts.contains(it.id)) { "an outgoing payment part with id=${it.id} already exists" } } + parts.forEach { outgoingParts[it.id] = Pair(parentId, it) } + } + + override suspend fun completeOutgoingLightningPart(partId: UUID, failure: Either, completedAt: Long) { + require(outgoingParts.contains(partId)) { "outgoing payment part with id=$partId doesn't exist" } + val (parentId, part) = outgoingParts[partId]!! + outgoingParts[partId] = Pair(parentId, part.copy(status = OutgoingPaymentFailure.convertFailure(failure, completedAt))) + } + + override suspend fun completeOutgoingLightningPart(partId: UUID, preimage: ByteVector32, completedAt: Long) { + require(outgoingParts.contains(partId)) { "outgoing payment part with id=$partId doesn't exist" } + val (parentId, part) = outgoingParts[partId]!! + outgoingParts[partId] = Pair(parentId, part.copy(status = LightningOutgoingPayment.Part.Status.Succeeded(preimage, completedAt))) + } + + override suspend fun getLightningOutgoingPaymentFromPartId(partId: UUID): LightningOutgoingPayment? { + return outgoingParts[partId]?.let { (parentId, _) -> + require(outgoing.contains(parentId)) { "parent outgoing payment with id=$parentId doesn't exist" } + getLightningOutgoingPayment(parentId) + } + } + + override suspend fun listLightningOutgoingPayments(paymentHash: ByteVector32): List { + return outgoing.values.filter { it.paymentHash == paymentHash }.map { payment -> + val parts = outgoingParts.values.filter { it.first == payment.id }.map { it.second } + payment.copy(parts = parts) + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt new file mode 100644 index 0000000..a02e288 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/Main.kt @@ -0,0 +1,339 @@ +package fr.acinq.lightning.bin + +import co.touchlab.kermit.CommonWriter +import co.touchlab.kermit.Severity +import co.touchlab.kermit.StaticConfig +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.context +import com.github.ajalt.clikt.core.terminal +import com.github.ajalt.clikt.output.MordantHelpFormatter +import com.github.ajalt.clikt.parameters.groups.OptionGroup +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.* +import com.github.ajalt.clikt.parameters.types.choice +import com.github.ajalt.clikt.parameters.types.int +import com.github.ajalt.clikt.parameters.types.restrictTo +import com.github.ajalt.clikt.sources.MapValueSource +import com.github.ajalt.mordant.rendering.TextColors.* +import com.github.ajalt.mordant.rendering.TextStyles.bold +import com.github.ajalt.mordant.rendering.TextStyles.underline +import fr.acinq.bitcoin.Chain +import fr.acinq.lightning.* +import fr.acinq.lightning.Lightning.randomBytes32 +import fr.acinq.lightning.bin.conf.LSP +import fr.acinq.lightning.bin.conf.getOrGenerateSeed +import fr.acinq.lightning.bin.conf.readConfFile +import fr.acinq.lightning.bin.db.SqliteChannelsDb +import fr.acinq.lightning.bin.db.SqlitePaymentsDb +import fr.acinq.lightning.bin.json.ApiType +import fr.acinq.lightning.bin.logs.FileLogWriter +import fr.acinq.lightning.blockchain.electrum.ElectrumClient +import fr.acinq.lightning.blockchain.electrum.ElectrumConnectionStatus +import fr.acinq.lightning.blockchain.electrum.ElectrumWatcher +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.lightning.db.ChannelsDb +import fr.acinq.lightning.db.Databases +import fr.acinq.lightning.db.PaymentsDb +import fr.acinq.lightning.io.Peer +import fr.acinq.lightning.io.TcpSocket +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.payment.LiquidityPolicy +import fr.acinq.lightning.utils.Connection +import fr.acinq.lightning.utils.ServerAddress +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.cio.* +import io.ktor.server.engine.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import okio.FileSystem +import okio.buffer +import okio.use +import kotlin.system.exitProcess +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + + +fun main(args: Array) = Phoenixd().main(args) + +class LiquidityOptions : OptionGroup(name = "Liquidity Options") { + val autoLiquidity by option("--auto-liquidity", help = "Amount automatically requested when inbound liquidity is needed").choice( + "off" to 0.sat, + "2m" to 2_000_000.sat, + "5m" to 5_000_000.sat, + "10m" to 10_000_000.sat, + ).default(2_000_000.sat) + val maxAbsoluteFee by option("--max-absolute-fee", help = "Max absolute fee for on-chain operations. Includes mining fee and service fee for auto-liquidity.") + .int().convert { it.sat } + .restrictTo(5_000.sat..100_000.sat) + .default(40_000.sat) // with a default auto-liquidity of 2m sat, that's a max total fee of 2% + val maxRelativeFeeBasisPoint by option("--max-relative-fee-percent", help = "Max relative fee for on-chain operations in percent.", hidden = true) + .int() + .restrictTo(1..50) + .default(30) + val maxFeeCredit by option("--max-fee-credit", help = "Max fee credit, if reached payments will be rejected.", hidden = true) + .int().convert { it.sat } + .restrictTo(0.sat..100_000.sat) + .default(100_000.sat) +} + +class Phoenixd : CliktCommand() { + //private val datadir by option("--datadir", help = "Data directory").convert { it.toPath() }.default(homeDirectory / ".phoenix", defaultForHelp = "~/.phoenix") + private val datadir = homeDirectory / ".phoenix" + private val confFile = datadir / "phoenix.conf" + private val chain by option("--chain", help = "Bitcoin chain to use").choice( + "mainnet" to Chain.Mainnet, "testnet" to Chain.Testnet + ).default(Chain.Testnet, defaultForHelp = "testnet") + private val customElectrumServer by option("--electrum-server", "-e", help = "Custom Electrum server") + .convert { it.split(":").run { ServerAddress(first(), last().toInt(), TcpSocket.TLS.DISABLED) } } + private val httpBindIp by option("--http-bind-ip", help = "Bind ip for the http api").default("127.0.0.1") + private val httpBindPort by option("--http-bind-port", help = "Bind port for the http api").int().default(9740) + private val httpPassword by option("--http-password", help = "Password for the http api").defaultLazy { + // the additionalValues map already contains values in phoenix.conf, so if we are here then there are no existing password + terminal.print(yellow("Generating default api password...")) + val value = randomBytes32().toHex() + val confFile = datadir / "phoenix.conf" + FileSystem.SYSTEM.createDirectories(datadir) + FileSystem.SYSTEM.appendingSink(confFile, mustExist = false).buffer().use { it.writeUtf8("\nhttp-password=$value\n") } + terminal.println(white("done")) + value + } + private val webHookUrl by option("--webhook", help = "Webhook http endpoint for push notifications (alternative to websocket)") + .convert { Url(it) } + + private val liquidityOptions by LiquidityOptions() + + private val verbose: Boolean by option("--verbose", help = "Verbose mode").flag(default = false) + private val silent: Boolean by option("--silent", "-s", help = "Silent mode").flag(default = false) + + init { + context { + valueSource = MapValueSource(readConfFile(confFile)) + helpFormatter = { MordantHelpFormatter(it, showDefaultValues = true) } + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun run() { + FileSystem.SYSTEM.createDirectories(datadir) + val (seed, new) = getOrGenerateSeed(datadir) + if (new) { + runBlocking { + terminal.print(yellow("Generating new seed...")) + delay(500.milliseconds) + terminal.println(white("done")) + terminal.println() + terminal.println(green("Backup")) + terminal.println("This software is self-custodial, you have full control and responsibility over your funds.") + terminal.println("Your 12-words seed is located in ${FileSystem.SYSTEM.canonicalize(datadir)}, ${bold(red("make sure to do a backup or you risk losing your funds"))}.") + terminal.println() + terminal.println(green("How does it work?")) + terminal.println( + """ + When receiving a Lightning payment that doesn't fit within your existing channel, then: + - If the payment amount is large enough to cover mining fees and service fees for automated liquidity, then your channel will be created or enlarged right away. + - If the payment is too small, then the full amount is added to your fee credit. This credit will be used later to pay for future fees. ${bold(red("The fee credit is non-refundable"))}. + """.trimIndent() + ) + terminal.println() + terminal.println( + gray( + """ + Examples: + With the default settings, and assuming that current mining fees are 10k sat. The total fee for a + liquidity operation will be 10k sat (mining fee) + 20k sat (service fee for the 2m sat liquidity) = 30k sat. + + ${(underline + gray)("scenario A")}: you receive a continuous stream of tiny 100 sat payments + a) the first 299 incoming payments will be added to your fee credit + b) when receiving the 300th payment, a 2m sat channel will be created, with balance 0 sat on your side + c) the next 20 thousands payments will be received in your channel + d) back to a) + + ${(underline + gray)("scenario B")}: you receive a continuous stream of 50k sat payments + a) when receiving the first payment, a 1M sat channel will be created with balance 50k-30k=20k sat on your side + b) the next next 40 payments will be received in your channel, at that point balance is 2m sat + c) back to a) + + In both scenarios, the total average fee is the same: 30k/2m = 1.5%. + You can reduce this average fee further, by choosing a higher liquidity amount (option ${bold(white("--auto-liquidity"))}), + in exchange for higher upfront costs. The higher the liquidity amount, the less significant the cost of + mining fee in relative terms. + """.trimIndent() + ) + ) + terminal.println() + terminal.prompt("Please confirm by typing", choices = listOf("I understand that if I do not make a backup I risk losing my funds"), invalidChoiceMessage = "Please type those exact words:") + terminal.prompt( + "Please confirm by typing", + choices = listOf("I must not share the same seed with other phoenix instances (mobile or server) or I risk force closing my channels"), + invalidChoiceMessage = "Please type those exact words:" + ) + terminal.prompt("Please confirm by typing", choices = listOf("I accept that the fee credit is non-refundable"), invalidChoiceMessage = "Please type those exact words:") + terminal.println() + + } + } + echo(cyan("datadir: ${FileSystem.SYSTEM.canonicalize(datadir)}")) + echo(cyan("chain: $chain")) + echo(cyan("autoLiquidity: ${liquidityOptions.autoLiquidity}")) + + val scope = GlobalScope + val loggerFactory = LoggerFactory( + StaticConfig(minSeverity = Severity.Info, logWriterList = buildList { + // always log to file + add(FileLogWriter(datadir / "phoenix.log", scope)) + // only log to console if verbose mode is enabled + if (verbose) add(CommonWriter()) + }) + ) + val electrumServer = customElectrumServer ?: when (chain) { + Chain.Mainnet -> ServerAddress("electrum.acinq.co", 50001, TcpSocket.TLS.DISABLED) + Chain.Testnet -> ServerAddress("testnet1.electrum.acinq.co", 51001, TcpSocket.TLS.DISABLED) + else -> error("unsupported chain") + } + val lsp = LSP.from(chain) + val liquidityPolicy = LiquidityPolicy.Auto( + maxAbsoluteFee = liquidityOptions.maxAbsoluteFee, + maxRelativeFeeBasisPoints = liquidityOptions.maxRelativeFeeBasisPoint, + skipAbsoluteFeeCheck = false, + maxAllowedCredit = liquidityOptions.maxFeeCredit + ) + val keyManager = LocalKeyManager(seed, chain, lsp.swapInXpub) + val nodeParams = NodeParams(chain, loggerFactory, keyManager) + .run { + copy( + zeroConfPeers = setOf(lsp.walletParams.trampolineNode.id), + liquidityPolicy = MutableStateFlow(liquidityPolicy), + features = features.copy( + activated = buildMap { + putAll(features.activated) + put(Feature.FeeCredit, FeatureSupport.Optional) + } + ) + ) + } + echo(cyan("nodeid: ${nodeParams.nodeId}")) + + val electrum = ElectrumClient(scope, loggerFactory) + val paymentsDb = SqlitePaymentsDb(loggerFactory, createPaymentsDbDriver(datadir)) + val peer = Peer( + nodeParams = nodeParams, walletParams = lsp.walletParams, watcher = ElectrumWatcher(electrum, scope, loggerFactory), db = object : Databases { + override val channels: ChannelsDb + get() = SqliteChannelsDb(createAppDbDriver(datadir)) + override val payments: PaymentsDb + get() = paymentsDb + }, socketBuilder = TcpSocket.Builder(), scope + ) + + val eventsFlow: SharedFlow = MutableSharedFlow().run { + scope.launch { + launch { + nodeParams.nodeEvents + .collect { + when { + it is PaymentEvents.PaymentReceived && it.amount > 0.msat -> emit(ApiType.PaymentReceived(it)) + else -> {} + } + } + } + launch { + peer.eventsFlow + .collect { + when { + it is fr.acinq.lightning.io.PaymentSent -> emit(ApiType.PaymentSent(it)) + else -> {} + } + } + } + } + asSharedFlow() + } + + val listeners = scope.launch { + launch { + // drop initial CLOSED event + electrum.connectionStatus.dropWhile { it is ElectrumConnectionStatus.Closed }.collect { + when (it) { + is ElectrumConnectionStatus.Connecting -> echo(yellow("connecting to electrum server...")) + is ElectrumConnectionStatus.Connected -> echo(yellow("connected to electrum server")) + is ElectrumConnectionStatus.Closed -> echo(yellow("disconnected from electrum server")) + } + } + } + launch { + // drop initial CLOSED event + peer.connectionState.dropWhile { it is Connection.CLOSED }.collect { + when (it) { + Connection.ESTABLISHING -> echo(yellow("connecting to lightning peer...")) + Connection.ESTABLISHED -> echo(yellow("connected to lightning peer")) + is Connection.CLOSED -> echo(yellow("disconnected from lightning peer")) + } + } + } + launch { + nodeParams.nodeEvents + .filterIsInstance() + .filter { it.amount > 0.msat } + .collect { + echo("received lightning payment: ${it.amount.truncateToSatoshi()} (${it.receivedWith.joinToString { part -> part::class.simpleName.toString().lowercase() }})") + } + } + launch { + nodeParams.nodeEvents + .filterIsInstance() + .collect { + echo(yellow("lightning payment rejected: amount=${it.amount.truncateToSatoshi()} fee=${it.fee.truncateToSatoshi()} maxFee=${liquidityPolicy.maxAbsoluteFee}")) + } + } + launch { + nodeParams.feeCredit + .drop(1) // we drop the initial value which is 0 sat + .collect { feeCredit -> echo("fee credit: $feeCredit") } + } + } + + runBlocking { + electrum.connect(electrumServer, TcpSocket.Builder()) + peer.connect(connectTimeout = 10.seconds, handshakeTimeout = 10.seconds) + peer.connectionState.first { it == Connection.ESTABLISHED } + peer.registerFcmToken("super-${randomBytes32().toHex()}") + peer.setAutoLiquidityParams(liquidityOptions.autoLiquidity) + } + + val server = embeddedServer(CIO, port = httpBindPort, host = httpBindIp, + configure = { + reuseAddress = true + }, + module = { + Api(nodeParams, peer, eventsFlow, httpPassword, webHookUrl).run { module() } + } + ) + val serverJob = scope.launch { + try { + server.start(wait = true) + } catch (t: Throwable) { + if (t.cause?.message?.contains("Address already in use") == true) { + echo(t.cause?.message, err = true) + } else throw t + } + } + + server.environment.monitor.subscribe(ServerReady) { + echo("listening on http://$httpBindIp:$httpBindPort") + } + server.environment.monitor.subscribe(ApplicationStopPreparing) { + echo(brightYellow("shutting down...")) + electrum.stop() + peer.disconnect() + server.stop() + listeners.cancel() + exitProcess(0) + } + server.environment.monitor.subscribe(ApplicationStopped) { echo(brightYellow("http server stopped")) } + + runBlocking { serverJob.join() } + } + +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/conf/ConfFile.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/conf/ConfFile.kt new file mode 100644 index 0000000..9549f49 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/conf/ConfFile.kt @@ -0,0 +1,19 @@ +package fr.acinq.lightning.bin.conf + +import okio.FileSystem +import okio.Path + +fun readConfFile(confFile: Path): Map = try { + buildMap { + if (FileSystem.SYSTEM.exists(confFile)) { + FileSystem.SYSTEM.read(confFile) { + while (true) { + val line = readUtf8Line() ?: break + line.split("=").run { put(first(), last()) } + } + } + } + } +} catch (t: Throwable) { + emptyMap() +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/conf/Lsp.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/conf/Lsp.kt new file mode 100644 index 0000000..24ecf23 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/conf/Lsp.kt @@ -0,0 +1,79 @@ +package fr.acinq.lightning.bin.conf + +import fr.acinq.bitcoin.Chain +import fr.acinq.bitcoin.PublicKey +import fr.acinq.lightning.* +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.utils.sat + + +data class LSP(val walletParams: WalletParams, val swapInXpub: String) { + + companion object { + + private val trampolineFees = listOf( + TrampolineFees( + feeBase = 4.sat, + feeProportional = 4_000, + cltvExpiryDelta = CltvExpiryDelta(576) + ) + ) + + private val invoiceDefaultRoutingFees = InvoiceDefaultRoutingFees( + feeBase = 1_000.msat, + feeProportional = 100, + cltvExpiryDelta = CltvExpiryDelta(144) + ) + + private val swapInParams = SwapInParams( + minConfirmations = DefaultSwapInParams.MinConfirmations, + maxConfirmations = DefaultSwapInParams.MaxConfirmations, + refundDelay = DefaultSwapInParams.RefundDelay, + ) + + fun from(chain: Chain) = when (chain) { + is Chain.Mainnet -> LSP( + swapInXpub = "xpub69q3sDXXsLuHVbmTrhqmEqYqTTsXJKahdfawXaYuUt6muf1PbZBnvqzFcwiT8Abpc13hY8BFafakwpPbVkatg9egwiMjed1cRrPM19b2Ma7", + walletParams = WalletParams( + trampolineNode = NodeUri(PublicKey.fromHex("03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f"), "3.33.236.230", 9735), + trampolineFees, + invoiceDefaultRoutingFees, + swapInParams + ) + ) + is Chain.Testnet -> LSP( + swapInXpub = "tpubDAmCFB21J9ExKBRPDcVxSvGs9jtcf8U1wWWbS1xTYmnUsuUHPCoFdCnEGxLE3THSWcQE48GHJnyz8XPbYUivBMbLSMBifFd3G9KmafkM9og", + walletParams = WalletParams( + trampolineNode = NodeUri(PublicKey.fromHex("03933884aaf1d6b108397e5efe5c86bcf2d8ca8d2f700eda99db9214fc2712b134"), "13.248.222.197", 9735), + trampolineFees, + invoiceDefaultRoutingFees, + swapInParams + ) + ) + else -> error("unsupported chain $chain") + } + +// fun liquidityLeaseRate(amount: Satoshi): LiquidityAds.LeaseRate { +// // WARNING : THIS MUST BE KEPT IN SYNC WITH LSP OTHERWISE FUNDING REQUEST WILL BE REJECTED BY PHOENIX +// val fundingWeight = if (amount <= 100_000.sat) { +// 271 * 2 // 2-inputs (wpkh) / 0-change +// } else if (amount <= 250_000.sat) { +// 271 * 2 // 2-inputs (wpkh) / 0-change +// } else if (amount <= 500_000.sat) { +// 271 * 4 // 4-inputs (wpkh) / 0-change +// } else if (amount <= 1_000_000.sat) { +// 271 * 4 // 4-inputs (wpkh) / 0-change +// } else { +// 271 * 6 // 6-inputs (wpkh) / 0-change +// } +// return LiquidityAds.LeaseRate( +// leaseDuration = 0, +// fundingWeight = fundingWeight, +// leaseFeeProportional = 100, // 1% +// leaseFeeBase = 0.sat, +// maxRelayFeeProportional = 100, +// maxRelayFeeBase = 1_000.msat +// ) +// } + } +} diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/conf/Seed.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/conf/Seed.kt new file mode 100644 index 0000000..f5adb04 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/conf/Seed.kt @@ -0,0 +1,24 @@ +package fr.acinq.lightning.bin.conf + +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.lightning.Lightning.randomBytes +import fr.acinq.lightning.utils.toByteVector +import okio.FileSystem +import okio.Path + +/** + * @return a pair with the seed and a boolean indicating whether the seed was newly generated + */ +fun getOrGenerateSeed(dir: Path): Pair { + val file = dir / "seed.dat" + val (mnemonics, new) = if (FileSystem.SYSTEM.exists(file)) { + FileSystem.SYSTEM.read(file) { readUtf8() } to false + } else { + val entropy = randomBytes(16) + val mnemonics = MnemonicCode.toMnemonics(entropy).joinToString(" ") + FileSystem.SYSTEM.write(file) { writeUtf8(mnemonics) } + mnemonics to true + } + return MnemonicCode.toSeed(mnemonics, "").toByteVector() to new +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/SqliteChannelsDb.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/SqliteChannelsDb.kt new file mode 100644 index 0000000..fc61922 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/SqliteChannelsDb.kt @@ -0,0 +1,70 @@ +package fr.acinq.lightning.bin.db + +import app.cash.sqldelight.db.SqlDriver +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.lightning.CltvExpiry +import fr.acinq.lightning.channel.states.PersistedChannelState +import fr.acinq.lightning.db.ChannelsDb +import fr.acinq.lightning.serialization.Serialization +import fr.acinq.phoenix.db.ChannelsDatabase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal class SqliteChannelsDb(private val driver: SqlDriver) : ChannelsDb { + + private val database = ChannelsDatabase(driver) + private val queries = database.channelsDatabaseQueries + + override suspend fun addOrUpdateChannel(state: PersistedChannelState) { + val channelId = state.channelId.toByteArray() + val data = Serialization.serialize(state) + withContext(Dispatchers.Default) { + queries.transaction { + queries.getChannel(channelId).executeAsOneOrNull()?.run { + queries.updateChannel(channel_id = this.channel_id, data_ = data) + } ?: run { + queries.insertChannel(channel_id = channelId, data_ = data) + } + } + } + } + + override suspend fun removeChannel(channelId: ByteVector32) { + withContext(Dispatchers.Default) { + queries.deleteHtlcInfo(channel_id = channelId.toByteArray()) + queries.closeLocalChannel(channel_id = channelId.toByteArray()) + } + } + + override suspend fun listLocalChannels(): List = withContext(Dispatchers.Default) { + val bytes = queries.listLocalChannels().executeAsList() + bytes.mapNotNull { + when (val res = Serialization.deserialize(it)) { + is Serialization.DeserializationResult.Success -> res.state + is Serialization.DeserializationResult.UnknownVersion -> null + } + } + } + + override suspend fun addHtlcInfo(channelId: ByteVector32, commitmentNumber: Long, paymentHash: ByteVector32, cltvExpiry: CltvExpiry) { + withContext(Dispatchers.Default) { + queries.insertHtlcInfo( + channel_id = channelId.toByteArray(), + commitment_number = commitmentNumber, + payment_hash = paymentHash.toByteArray(), + cltv_expiry = cltvExpiry.toLong()) + } + } + + override suspend fun listHtlcInfos(channelId: ByteVector32, commitmentNumber: Long): List> { + return withContext(Dispatchers.Default) { + queries.listHtlcInfos(channel_id = channelId.toByteArray(), commitment_number = commitmentNumber, mapper = { payment_hash, cltv_expiry -> + ByteVector32(payment_hash) to CltvExpiry(cltv_expiry) + }).executeAsList() + } + } + + override fun close() { + driver.close() + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/SqlitePaymentsDb.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/SqlitePaymentsDb.kt new file mode 100644 index 0000000..390fbe3 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/SqlitePaymentsDb.kt @@ -0,0 +1,289 @@ +/* + * Copyright 2020 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.lightning.bin.db + +import app.cash.sqldelight.EnumColumnAdapter +import app.cash.sqldelight.db.SqlDriver +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Crypto +import fr.acinq.bitcoin.TxId +import fr.acinq.bitcoin.utils.Either +import fr.acinq.lightning.bin.db.payments.* +import fr.acinq.lightning.bin.db.payments.LinkTxToPaymentQueries +import fr.acinq.lightning.channel.ChannelException +import fr.acinq.lightning.db.* +import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.payment.FinalFailure +import fr.acinq.lightning.utils.* +import fr.acinq.lightning.wire.FailureMessage +import fr.acinq.phoenix.db.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +class SqlitePaymentsDb( + loggerFactory: LoggerFactory, + private val driver: SqlDriver, +) : PaymentsDb { + + private val log = loggerFactory.newLogger(this::class) + + private val database = PaymentsDatabase( + driver = driver, + outgoing_payment_partsAdapter = Outgoing_payment_parts.Adapter( + part_routeAdapter = OutgoingQueries.hopDescAdapter, + part_status_typeAdapter = EnumColumnAdapter() + ), + outgoing_paymentsAdapter = Outgoing_payments.Adapter( + status_typeAdapter = EnumColumnAdapter(), + details_typeAdapter = EnumColumnAdapter() + ), + incoming_paymentsAdapter = Incoming_payments.Adapter( + origin_typeAdapter = EnumColumnAdapter(), + received_with_typeAdapter = EnumColumnAdapter() + ), + outgoing_payment_closing_tx_partsAdapter = Outgoing_payment_closing_tx_parts.Adapter( + part_closing_info_typeAdapter = EnumColumnAdapter() + ), + channel_close_outgoing_paymentsAdapter = Channel_close_outgoing_payments.Adapter( + closing_info_typeAdapter = EnumColumnAdapter() + ), + inbound_liquidity_outgoing_paymentsAdapter = Inbound_liquidity_outgoing_payments.Adapter( + lease_typeAdapter = EnumColumnAdapter() + ) + ) + + internal val inQueries = IncomingQueries(database) + internal val outQueries = OutgoingQueries(database) + private val spliceOutQueries = SpliceOutgoingQueries(database) + private val channelCloseQueries = ChannelCloseOutgoingQueries(database) + private val cpfpQueries = SpliceCpfpOutgoingQueries(database) + private val linkTxToPaymentQueries = LinkTxToPaymentQueries(database) + private val inboundLiquidityQueries = InboundLiquidityQueries(database) + + override suspend fun addOutgoingLightningParts( + parentId: UUID, + parts: List + ) { + withContext(Dispatchers.Default) { + outQueries.addLightningParts(parentId, parts) + } + } + + override suspend fun addOutgoingPayment( + outgoingPayment: OutgoingPayment + ) { + withContext(Dispatchers.Default) { + database.transaction { + when (outgoingPayment) { + is LightningOutgoingPayment -> { + outQueries.addLightningOutgoingPayment(outgoingPayment) + } + is SpliceOutgoingPayment -> { + spliceOutQueries.addSpliceOutgoingPayment(outgoingPayment) + linkTxToPaymentQueries.linkTxToPayment( + txId = outgoingPayment.txId, + walletPaymentId = outgoingPayment.walletPaymentId() + ) + } + is ChannelCloseOutgoingPayment -> { + channelCloseQueries.addChannelCloseOutgoingPayment(outgoingPayment) + linkTxToPaymentQueries.linkTxToPayment( + txId = outgoingPayment.txId, + walletPaymentId = outgoingPayment.walletPaymentId() + ) + } + is SpliceCpfpOutgoingPayment -> { + cpfpQueries.addCpfpPayment(outgoingPayment) + linkTxToPaymentQueries.linkTxToPayment(outgoingPayment.txId, outgoingPayment.walletPaymentId()) + } + is InboundLiquidityOutgoingPayment -> { + inboundLiquidityQueries.add(outgoingPayment) + linkTxToPaymentQueries.linkTxToPayment(outgoingPayment.txId, outgoingPayment.walletPaymentId()) + } + } + } + } + } + + override suspend fun completeOutgoingPaymentOffchain( + id: UUID, + preimage: ByteVector32, + completedAt: Long + ) { + withContext(Dispatchers.Default) { + outQueries.completePayment(id, LightningOutgoingPayment.Status.Completed.Succeeded.OffChain(preimage, completedAt)) + } + } + + override suspend fun completeOutgoingPaymentOffchain( + id: UUID, + finalFailure: FinalFailure, + completedAt: Long + ) { + withContext(Dispatchers.Default) { + outQueries.completePayment(id, LightningOutgoingPayment.Status.Completed.Failed(finalFailure, completedAt)) + } + } + + override suspend fun completeOutgoingLightningPart( + partId: UUID, + preimage: ByteVector32, + completedAt: Long + ) { + withContext(Dispatchers.Default) { + outQueries.updateLightningPart(partId, preimage, completedAt) + } + } + + override suspend fun completeOutgoingLightningPart( + partId: UUID, + failure: Either, + completedAt: Long + ) { + withContext(Dispatchers.Default) { + outQueries.updateLightningPart(partId, failure, completedAt) + } + } + + override suspend fun getLightningOutgoingPayment(id: UUID): LightningOutgoingPayment? = withContext(Dispatchers.Default) { + outQueries.getPaymentStrict(id) + } + + override suspend fun getLightningOutgoingPaymentFromPartId(partId: UUID): LightningOutgoingPayment? = withContext(Dispatchers.Default) { + outQueries.getPaymentFromPartId(partId) + } + + // ---- list outgoing + + override suspend fun listLightningOutgoingPayments( + paymentHash: ByteVector32 + ): List = withContext(Dispatchers.Default) { + outQueries.listLightningOutgoingPayments(paymentHash) + } + + // ---- incoming payments + + override suspend fun addIncomingPayment( + preimage: ByteVector32, + origin: IncomingPayment.Origin, + createdAt: Long + ): IncomingPayment { + val paymentHash = Crypto.sha256(preimage).toByteVector32() + + return withContext(Dispatchers.Default) { + database.transactionWithResult { + inQueries.addIncomingPayment(preimage, paymentHash, origin, createdAt) + inQueries.getIncomingPayment(paymentHash)!! + } + } + } + + override suspend fun receivePayment( + paymentHash: ByteVector32, + receivedWith: List, + receivedAt: Long + ) { + withContext(Dispatchers.Default) { + database.transaction { + inQueries.receivePayment(paymentHash, receivedWith, receivedAt) + // if one received-with is on-chain, save the tx id to the db + receivedWith.filterIsInstance().firstOrNull()?.let { + linkTxToPaymentQueries.linkTxToPayment(it.txId, WalletPaymentId.IncomingPaymentId(paymentHash)) + } + } + } + } + + override suspend fun setLocked(txId: TxId) { + database.transaction { + val lockedAt = currentTimestampMillis() + linkTxToPaymentQueries.setLocked(txId, lockedAt) + linkTxToPaymentQueries.listWalletPaymentIdsForTx(txId).forEach { walletPaymentId -> + when (walletPaymentId) { + is WalletPaymentId.IncomingPaymentId -> { + inQueries.setLocked(walletPaymentId.paymentHash, lockedAt) + } + is WalletPaymentId.LightningOutgoingPaymentId -> { + // LN payments need not be locked + } + is WalletPaymentId.SpliceOutgoingPaymentId -> { + spliceOutQueries.setLocked(walletPaymentId.id, lockedAt) + } + is WalletPaymentId.ChannelCloseOutgoingPaymentId -> { + channelCloseQueries.setLocked(walletPaymentId.id, lockedAt) + } + is WalletPaymentId.SpliceCpfpOutgoingPaymentId -> { + cpfpQueries.setLocked(walletPaymentId.id, lockedAt) + } + is WalletPaymentId.InboundLiquidityOutgoingPaymentId -> { + inboundLiquidityQueries.setLocked(walletPaymentId.id, lockedAt) + } + } + } + } + } + + suspend fun setConfirmed(txId: TxId) = withContext(Dispatchers.Default) { + database.transaction { + val confirmedAt = currentTimestampMillis() + linkTxToPaymentQueries.setConfirmed(txId, confirmedAt) + linkTxToPaymentQueries.listWalletPaymentIdsForTx(txId).forEach { walletPaymentId -> + when (walletPaymentId) { + is WalletPaymentId.IncomingPaymentId -> { + inQueries.setConfirmed(walletPaymentId.paymentHash, confirmedAt) + } + is WalletPaymentId.LightningOutgoingPaymentId -> { + // LN payments need not be confirmed + } + is WalletPaymentId.SpliceOutgoingPaymentId -> { + spliceOutQueries.setConfirmed(walletPaymentId.id, confirmedAt) + } + is WalletPaymentId.ChannelCloseOutgoingPaymentId -> { + channelCloseQueries.setConfirmed(walletPaymentId.id, confirmedAt) + } + is WalletPaymentId.SpliceCpfpOutgoingPaymentId -> { + cpfpQueries.setConfirmed(walletPaymentId.id, confirmedAt) + } + is WalletPaymentId.InboundLiquidityOutgoingPaymentId -> { + inboundLiquidityQueries.setConfirmed(walletPaymentId.id, confirmedAt) + } + } + } + } + } + + override suspend fun getIncomingPayment( + paymentHash: ByteVector32 + ): IncomingPayment? = withContext(Dispatchers.Default) { + inQueries.getIncomingPayment(paymentHash) + } + + override suspend fun listExpiredPayments( + fromCreatedAt: Long, + toCreatedAt: Long + ): List = withContext(Dispatchers.Default) { + inQueries.listExpiredPayments(fromCreatedAt, toCreatedAt) + } + + override suspend fun removeIncomingPayment( + paymentHash: ByteVector32 + ): Boolean = withContext(Dispatchers.Default) { + inQueries.deleteIncomingPayment(paymentHash) + } + +} diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/WalletPaymentId.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/WalletPaymentId.kt new file mode 100644 index 0000000..7e50b5a --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/WalletPaymentId.kt @@ -0,0 +1,119 @@ +package fr.acinq.lightning.bin.db + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.lightning.db.* +import fr.acinq.lightning.utils.UUID + +/** + * Helper class that helps to link an actual payment to a unique string. This is useful to store a reference + * to a payment in a single TEXT sql column. + * + * e.g.: incoming|b50ccb7e52ecc6f25b21eb23c2efdd1cfdb973ca12c7db9eef3d818dcc9b437c + * This is a unique identifier for an [IncomingPayment] with paymentHash=b50ccb7...b437c. + * + * It is common to reference these rows in other database tables via [dbType] or [dbId]. + * + * @param dbType Long representing either incoming or outgoing/splice-outgoing/... + * @param dbId String representing the appropriate id for either table (payment hash or UUID). + */ +sealed class WalletPaymentId { + + abstract val dbType: DbType + abstract val dbId: String + + /** Use this to get a single (hashable) identifier for the row, for example within a hashmap or Cache. */ + abstract val identifier: String + + data class IncomingPaymentId(val paymentHash: ByteVector32) : WalletPaymentId() { + override val dbType: DbType = DbType.INCOMING + override val dbId: String = paymentHash.toHex() + override val identifier: String = "incoming|$dbId" + + companion object { + fun fromString(id: String) = IncomingPaymentId(paymentHash = ByteVector32(id)) + fun fromByteArray(id: ByteArray) = IncomingPaymentId(paymentHash = ByteVector32(id)) + } + } + + data class LightningOutgoingPaymentId(val id: UUID) : WalletPaymentId() { + override val dbType: DbType = DbType.OUTGOING + override val dbId: String = id.toString() + override val identifier: String = "outgoing|$dbId" + + companion object { + fun fromString(id: String) = LightningOutgoingPaymentId(id = UUID.fromString(id)) + } + } + + data class SpliceOutgoingPaymentId(val id: UUID) : WalletPaymentId() { + override val dbType: DbType = DbType.SPLICE_OUTGOING + override val dbId: String = id.toString() + override val identifier: String = "splice_outgoing|$dbId" + + companion object { + fun fromString(id: String) = SpliceOutgoingPaymentId(id = UUID.fromString(id)) + } + } + + data class ChannelCloseOutgoingPaymentId(val id: UUID) : WalletPaymentId() { + override val dbType: DbType = DbType.CHANNEL_CLOSE_OUTGOING + override val dbId: String = id.toString() + override val identifier: String = "channel_close_outgoing|$dbId" + + companion object { + fun fromString(id: String) = ChannelCloseOutgoingPaymentId(id = UUID.fromString(id)) + } + } + + data class SpliceCpfpOutgoingPaymentId(val id: UUID) : WalletPaymentId() { + override val dbType: DbType = DbType.SPLICE_CPFP_OUTGOING + override val dbId: String = id.toString() + override val identifier: String = "splice_cpfp_outgoing|$dbId" + + companion object { + fun fromString(id: String) = SpliceCpfpOutgoingPaymentId(id = UUID.fromString(id)) + } + } + + data class InboundLiquidityOutgoingPaymentId(val id: UUID) : WalletPaymentId() { + override val dbType: DbType = DbType.INBOUND_LIQUIDITY_OUTGOING + override val dbId: String = id.toString() + override val identifier: String = "inbound_liquidity_outgoing|$dbId" + + companion object { + fun fromString(id: String) = InboundLiquidityOutgoingPaymentId(id = UUID.fromString(id)) + } + } + + enum class DbType(val value: Long) { + INCOMING(1), + OUTGOING(2), + SPLICE_OUTGOING(3), + CHANNEL_CLOSE_OUTGOING(4), + SPLICE_CPFP_OUTGOING(5), + INBOUND_LIQUIDITY_OUTGOING(6), + } + + companion object { + fun create(type: Long, id: String): WalletPaymentId? { + return when (type) { + DbType.INCOMING.value -> IncomingPaymentId.fromString(id) + DbType.OUTGOING.value -> LightningOutgoingPaymentId.fromString(id) + DbType.SPLICE_OUTGOING.value -> SpliceOutgoingPaymentId.fromString(id) + DbType.CHANNEL_CLOSE_OUTGOING.value -> ChannelCloseOutgoingPaymentId.fromString(id) + DbType.SPLICE_CPFP_OUTGOING.value -> SpliceCpfpOutgoingPaymentId.fromString(id) + DbType.INBOUND_LIQUIDITY_OUTGOING.value -> InboundLiquidityOutgoingPaymentId.fromString(id) + else -> null + } + } + } +} + +fun WalletPayment.walletPaymentId(): WalletPaymentId = when (this) { + is IncomingPayment -> WalletPaymentId.IncomingPaymentId(paymentHash = this.paymentHash) + is LightningOutgoingPayment -> WalletPaymentId.LightningOutgoingPaymentId(id = this.id) + is SpliceOutgoingPayment -> WalletPaymentId.SpliceOutgoingPaymentId(id = this.id) + is ChannelCloseOutgoingPayment -> WalletPaymentId.ChannelCloseOutgoingPaymentId(id = this.id) + is SpliceCpfpOutgoingPayment -> WalletPaymentId.SpliceCpfpOutgoingPaymentId(id = this.id) + is InboundLiquidityOutgoingPayment -> WalletPaymentId.InboundLiquidityOutgoingPaymentId(id = this.id) +} diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/ChannelCloseOutgoingQueries.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/ChannelCloseOutgoingQueries.kt new file mode 100644 index 0000000..62a38c5 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/ChannelCloseOutgoingQueries.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2023 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.lightning.bin.db.payments + +import fr.acinq.bitcoin.TxId +import fr.acinq.lightning.db.ChannelCloseOutgoingPayment +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.utils.toByteVector32 +import fr.acinq.phoenix.db.PaymentsDatabase + +class ChannelCloseOutgoingQueries(val database: PaymentsDatabase) { + private val channelCloseQueries = database.channelCloseOutgoingPaymentsQueries + + fun getChannelCloseOutgoingPayment(id: UUID): ChannelCloseOutgoingPayment? { + return channelCloseQueries.getChannelCloseOutgoing(id.toString(), Companion::mapChannelCloseOutgoingPayment).executeAsOneOrNull() + } + + fun addChannelCloseOutgoingPayment(payment: ChannelCloseOutgoingPayment) { + val (closingInfoType, closingInfoBlob) = payment.mapClosingTypeToDb() + database.transaction { + channelCloseQueries.insertChannelCloseOutgoing( + id = payment.id.toString(), + recipient_amount_sat = payment.recipientAmount.sat, + address = payment.address, + is_default_address = if (payment.isSentToDefaultAddress) 1 else 0, + mining_fees_sat = payment.miningFees.sat, + tx_id = payment.txId.value.toByteArray(), + created_at = payment.createdAt, + confirmed_at = payment.confirmedAt, + locked_at = payment.lockedAt, + channel_id = payment.channelId.toByteArray(), + closing_info_type = closingInfoType, + closing_info_blob = closingInfoBlob, + ) + } + } + + fun setConfirmed(id: UUID, confirmedAt: Long) { + database.transaction { + channelCloseQueries.setConfirmed(confirmed_at = confirmedAt, id = id.toString()) + } + } + + fun setLocked(id: UUID, lockedAt: Long) { + database.transaction { + channelCloseQueries.setLocked(locked_at = lockedAt, id = id.toString()) + } + } + + companion object { + fun mapChannelCloseOutgoingPayment( + id: String, + amount_sat: Long, + address: String, + is_default_address: Long, + mining_fees_sat: Long, + tx_id: ByteArray, + created_at: Long, + confirmed_at: Long?, + locked_at: Long?, + channel_id: ByteArray, + closing_info_type: OutgoingPartClosingInfoTypeVersion, + closing_info_blob: ByteArray + ): ChannelCloseOutgoingPayment { + return ChannelCloseOutgoingPayment( + id = UUID.fromString(id), + recipientAmount = amount_sat.sat, + address = address, + isSentToDefaultAddress = is_default_address == 1L, + miningFees = mining_fees_sat.sat, + txId = TxId(tx_id), + createdAt = created_at, + confirmedAt = confirmed_at, + lockedAt = locked_at, + channelId = channel_id.toByteVector32(), + closingType = OutgoingPartClosingInfoData.deserialize(closing_info_type, closing_info_blob), + ) + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/DbTypesHelper.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/DbTypesHelper.kt new file mode 100644 index 0000000..3947f46 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/DbTypesHelper.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2021 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.lightning.bin.db.payments + +import io.ktor.utils.io.charsets.* +import io.ktor.utils.io.core.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass + +object DbTypesHelper { + /** Decode a byte array and apply a deserialization handler. */ + fun decodeBlob(blob: ByteArray, handler: (String, Json) -> T) = handler(String(bytes = blob, charset = Charsets.UTF_8), Json) + + val module = SerializersModule { + polymorphic(IncomingReceivedWithData.Part::class) { + subclass(IncomingReceivedWithData.Part.Htlc.V0::class) + subclass(IncomingReceivedWithData.Part.NewChannel.V2::class) + subclass(IncomingReceivedWithData.Part.SpliceIn.V0::class) + subclass(IncomingReceivedWithData.Part.FeeCredit.V0::class) + } + } + + val polymorphicFormat = Json { serializersModule = module } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/InboundLiquidityLeaseType.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/InboundLiquidityLeaseType.kt new file mode 100644 index 0000000..2801eb9 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/InboundLiquidityLeaseType.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2023 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:UseSerializers( + ByteVectorSerializer::class, + ByteVector32Serializer::class, + ByteVector64Serializer::class, + SatoshiSerializer::class, + MilliSatoshiSerializer::class +) + +package fr.acinq.lightning.bin.db.payments + +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector64 +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment +import fr.acinq.lightning.wire.LiquidityAds +import fr.acinq.lightning.bin.db.serializers.v1.ByteVector32Serializer +import fr.acinq.lightning.bin.db.serializers.v1.ByteVector64Serializer +import fr.acinq.lightning.bin.db.serializers.v1.ByteVectorSerializer +import fr.acinq.lightning.bin.db.serializers.v1.MilliSatoshiSerializer +import fr.acinq.lightning.bin.db.serializers.v1.SatoshiSerializer +import io.ktor.utils.io.charsets.Charsets +import io.ktor.utils.io.core.toByteArray +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +enum class InboundLiquidityLeaseTypeVersion { + LEASE_V0, +} + +sealed class InboundLiquidityLeaseData { + + @Serializable + data class V0( + val amount: Satoshi, + val miningFees: Satoshi, + val serviceFee: Satoshi, + val sellerSig: ByteVector64, + val witnessFundingScript: ByteVector, + val witnessLeaseDuration: Int, + val witnessLeaseEnd: Int, + val witnessMaxRelayFeeProportional: Int, + val witnessMaxRelayFeeBase: MilliSatoshi + ) : InboundLiquidityLeaseData() + + companion object { + /** Deserializes a json-encoded blob containing data for an [LiquidityAds.Lease] object. */ + fun deserialize( + typeVersion: InboundLiquidityLeaseTypeVersion, + blob: ByteArray, + ): LiquidityAds.Lease = DbTypesHelper.decodeBlob(blob) { json, format -> + when (typeVersion) { + InboundLiquidityLeaseTypeVersion.LEASE_V0 -> format.decodeFromString(json).let { + LiquidityAds.Lease( + amount = it.amount, + fees = LiquidityAds.LeaseFees(miningFee = it.miningFees, serviceFee = it.serviceFee), + sellerSig = it.sellerSig, + witness = LiquidityAds.LeaseWitness( + fundingScript = it.witnessFundingScript, + leaseDuration = it.witnessLeaseDuration, + leaseEnd = it.witnessLeaseEnd, + maxRelayFeeProportional = it.witnessMaxRelayFeeProportional, + maxRelayFeeBase = it.witnessMaxRelayFeeBase, + ) + ) + } + } + } + } +} + +fun InboundLiquidityOutgoingPayment.mapLeaseToDb() = InboundLiquidityLeaseTypeVersion.LEASE_V0 to + InboundLiquidityLeaseData.V0( + amount = lease.amount, + miningFees = lease.fees.miningFee, + serviceFee = lease.fees.serviceFee, + sellerSig = lease.sellerSig, + witnessFundingScript = lease.witness.fundingScript, + witnessLeaseDuration = lease.witness.leaseDuration, + witnessLeaseEnd = lease.witness.leaseEnd, + witnessMaxRelayFeeProportional = lease.witness.maxRelayFeeProportional, + witnessMaxRelayFeeBase = lease.witness.maxRelayFeeBase, + ).let { + Json.encodeToString(it).toByteArray(Charsets.UTF_8) + } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/InboundLiquidityQueries.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/InboundLiquidityQueries.kt new file mode 100644 index 0000000..798ca68 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/InboundLiquidityQueries.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2023 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.lightning.bin.db.payments + +import fr.acinq.bitcoin.TxId +import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.utils.toByteVector32 +import fr.acinq.phoenix.db.PaymentsDatabase + +class InboundLiquidityQueries(val database: PaymentsDatabase) { + private val queries = database.inboundLiquidityOutgoingQueries + + fun add(payment: InboundLiquidityOutgoingPayment) { + database.transaction { + val (leaseType, leaseData) = payment.mapLeaseToDb() + queries.insert( + id = payment.id.toString(), + mining_fees_sat = payment.miningFees.sat, + channel_id = payment.channelId.toByteArray(), + tx_id = payment.txId.value.toByteArray(), + lease_type = leaseType, + lease_blob = leaseData, + created_at = payment.createdAt, + confirmed_at = payment.confirmedAt, + locked_at = payment.lockedAt, + ) + } + } + + fun get(id: UUID): InboundLiquidityOutgoingPayment? { + return queries.get(id = id.toString(), mapper = Companion::mapPayment) + .executeAsOneOrNull() + } + + fun setConfirmed(id: UUID, confirmedAt: Long) { + database.transaction { + queries.setConfirmed(confirmed_at = confirmedAt, id = id.toString()) + } + } + + fun setLocked(id: UUID, lockedAt: Long) { + database.transaction { + queries.setLocked(locked_at = lockedAt, id = id.toString()) + } + } + + private companion object { + fun mapPayment( + id: String, + mining_fees_sat: Long, + channel_id: ByteArray, + tx_id: ByteArray, + lease_type: InboundLiquidityLeaseTypeVersion, + lease_blob: ByteArray, + created_at: Long, + confirmed_at: Long?, + locked_at: Long? + ): InboundLiquidityOutgoingPayment { + return InboundLiquidityOutgoingPayment( + id = UUID.fromString(id), + miningFees = mining_fees_sat.sat, + channelId = channel_id.toByteVector32(), + txId = TxId(tx_id), + lease = InboundLiquidityLeaseData.deserialize(lease_type, lease_blob), + createdAt = created_at, + confirmedAt = confirmed_at, + lockedAt = locked_at + ) + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/IncomingOriginType.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/IncomingOriginType.kt new file mode 100644 index 0000000..2539c79 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/IncomingOriginType.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2021 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:UseSerializers( + OutpointSerializer::class, + ByteVector32Serializer::class, +) + +package fr.acinq.lightning.bin.db.payments + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.OutPoint +import fr.acinq.bitcoin.TxId +import fr.acinq.lightning.bin.db.payments.DbTypesHelper.decodeBlob +import fr.acinq.lightning.db.IncomingPayment +import fr.acinq.lightning.payment.Bolt11Invoice +import fr.acinq.lightning.bin.db.serializers.v1.ByteVector32Serializer +import fr.acinq.lightning.bin.db.serializers.v1.OutpointSerializer +import io.ktor.utils.io.charsets.* +import io.ktor.utils.io.core.* +import kotlinx.serialization.* +import kotlinx.serialization.json.Json + + +enum class IncomingOriginTypeVersion { + KEYSEND_V0, + INVOICE_V0, + SWAPIN_V0, + ONCHAIN_V0, +} + +sealed class IncomingOriginData { + + sealed class KeySend : IncomingOriginData() { + @Serializable + @SerialName("KEYSEND_V0") + object V0 : KeySend() + } + + sealed class Invoice : IncomingOriginData() { + @Serializable + data class V0(val paymentRequest: String) : Invoice() + } + + sealed class SwapIn : IncomingOriginData() { + @Serializable + data class V0(val address: String?) : SwapIn() + } + + sealed class OnChain : IncomingOriginData() { + @Serializable + data class V0(@Serializable val txId: ByteVector32, val outpoints: List<@Serializable OutPoint>) : SwapIn() + } + + companion object { + fun deserialize(typeVersion: IncomingOriginTypeVersion, blob: ByteArray): IncomingPayment.Origin = decodeBlob(blob) { json, format -> + when (typeVersion) { + IncomingOriginTypeVersion.KEYSEND_V0 -> IncomingPayment.Origin.KeySend + IncomingOriginTypeVersion.INVOICE_V0 -> format.decodeFromString(json).let { IncomingPayment.Origin.Invoice(Bolt11Invoice.read(it.paymentRequest).get()) } + IncomingOriginTypeVersion.SWAPIN_V0 -> format.decodeFromString(json).let { IncomingPayment.Origin.SwapIn(it.address) } + IncomingOriginTypeVersion.ONCHAIN_V0 -> format.decodeFromString(json).let { + IncomingPayment.Origin.OnChain(TxId(it.txId), it.outpoints.toSet()) + } + } + } + } +} + +fun IncomingPayment.Origin.mapToDb(): Pair = when (this) { + is IncomingPayment.Origin.KeySend -> IncomingOriginTypeVersion.KEYSEND_V0 to + Json.encodeToString(IncomingOriginData.KeySend.V0).toByteArray(Charsets.UTF_8) + is IncomingPayment.Origin.Invoice -> IncomingOriginTypeVersion.INVOICE_V0 to + Json.encodeToString(IncomingOriginData.Invoice.V0(paymentRequest.write())).toByteArray(Charsets.UTF_8) + is IncomingPayment.Origin.SwapIn -> IncomingOriginTypeVersion.SWAPIN_V0 to + Json.encodeToString(IncomingOriginData.SwapIn.V0(address)).toByteArray(Charsets.UTF_8) + is IncomingPayment.Origin.OnChain -> IncomingOriginTypeVersion.ONCHAIN_V0 to + Json.encodeToString(IncomingOriginData.OnChain.V0(txId.value, localInputs.toList())).toByteArray(Charsets.UTF_8) +} diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/IncomingQueries.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/IncomingQueries.kt new file mode 100644 index 0000000..2c04335 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/IncomingQueries.kt @@ -0,0 +1,201 @@ +/* + * Copyright 2021 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.lightning.bin.db.payments + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.byteVector32 +import fr.acinq.lightning.db.IncomingPayment +import fr.acinq.lightning.utils.msat +import fr.acinq.phoenix.db.PaymentsDatabase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.Flow + +class IncomingQueries(private val database: PaymentsDatabase) { + + private val queries = database.incomingPaymentsQueries + + fun addIncomingPayment( + preimage: ByteVector32, + paymentHash: ByteVector32, + origin: IncomingPayment.Origin, + createdAt: Long + ) { + val (originType, originData) = origin.mapToDb() + queries.insert( + payment_hash = paymentHash.toByteArray(), + preimage = preimage.toByteArray(), + origin_type = originType, + origin_blob = originData, + created_at = createdAt + ) + } + + fun receivePayment( + paymentHash: ByteVector32, + receivedWith: List, + receivedAt: Long + ) { + database.transaction { + val paymentInDb = queries.get( + payment_hash = paymentHash.toByteArray(), + mapper = Companion::mapIncomingPayment + ).executeAsOneOrNull() ?: throw IncomingPaymentNotFound(paymentHash) + val existingReceivedWith = paymentInDb.received?.receivedWith ?: emptySet() + val newReceivedWith = existingReceivedWith + receivedWith + val (receivedWithType, receivedWithBlob) = newReceivedWith.mapToDb() ?: (null to null) + queries.updateReceived( + received_at = receivedAt, + received_with_type = receivedWithType, + received_with_blob = receivedWithBlob, + payment_hash = paymentHash.toByteArray() + ) + } + } + + fun setLocked(paymentHash: ByteVector32, lockedAt: Long) { + database.transaction { + val paymentInDb = queries.get( + payment_hash = paymentHash.toByteArray(), + mapper = Companion::mapIncomingPayment + ).executeAsOneOrNull() + val newReceivedWith = paymentInDb?.received?.receivedWith?.map { + when (it) { + is IncomingPayment.ReceivedWith.NewChannel -> it.copy(lockedAt = lockedAt) + is IncomingPayment.ReceivedWith.SpliceIn -> it.copy(lockedAt = lockedAt) + else -> it + } + } + val (newReceivedWithType, newReceivedWithBlob) = newReceivedWith?.mapToDb() + ?: (null to null) + queries.updateReceived( + // we override the previous received_at timestamp to trigger a refresh of the payment's cache data + // because the list-all query feeding the cache uses `received_at` for incoming payments + received_at = lockedAt, + received_with_type = newReceivedWithType, + received_with_blob = newReceivedWithBlob, + payment_hash = paymentHash.toByteArray() + ) + } + } + + fun setConfirmed(paymentHash: ByteVector32, confirmedAt: Long) { + database.transaction { + val paymentInDb = queries.get( + payment_hash = paymentHash.toByteArray(), + mapper = Companion::mapIncomingPayment + ).executeAsOneOrNull() + val newReceivedWith = paymentInDb?.received?.receivedWith?.map { + when (it) { + is IncomingPayment.ReceivedWith.NewChannel -> it.copy(confirmedAt = confirmedAt) + is IncomingPayment.ReceivedWith.SpliceIn -> it.copy(confirmedAt = confirmedAt) + else -> it + } + } + val (newReceivedWithType, newReceivedWithBlob) = newReceivedWith?.mapToDb() + ?: (null to null) + queries.updateReceived( + received_at = paymentInDb?.received?.receivedAt, + received_with_type = newReceivedWithType, + received_with_blob = newReceivedWithBlob, + payment_hash = paymentHash.toByteArray() + ) + } + } + + fun getIncomingPayment(paymentHash: ByteVector32): IncomingPayment? { + return queries.get(payment_hash = paymentHash.toByteArray(), Companion::mapIncomingPayment).executeAsOneOrNull() + } + + fun getOldestReceivedDate(): Long? { + return queries.getOldestReceivedDate().executeAsOneOrNull() + } + + fun listAllNotConfirmed(): Flow> { + return queries.listAllNotConfirmed(Companion::mapIncomingPayment).asFlow().mapToList(Dispatchers.IO) + } + + fun listExpiredPayments(fromCreatedAt: Long, toCreatedAt: Long): List { + return queries.listAllWithin(fromCreatedAt, toCreatedAt, Companion::mapIncomingPayment).executeAsList().filter { + it.received == null + } + } + + /** Try to delete an incoming payment ; return true if an element was deleted, false otherwise. */ + fun deleteIncomingPayment(paymentHash: ByteVector32): Boolean { + return database.transactionWithResult { + queries.delete(payment_hash = paymentHash.toByteArray()) + queries.changes().executeAsOne() != 0L + } + } + + companion object { + fun mapIncomingPayment( + @Suppress("UNUSED_PARAMETER") payment_hash: ByteArray, + preimage: ByteArray, + created_at: Long, + origin_type: IncomingOriginTypeVersion, + origin_blob: ByteArray, + @Suppress("UNUSED_PARAMETER") received_amount_msat: Long?, + received_at: Long?, + received_with_type: IncomingReceivedWithTypeVersion?, + received_with_blob: ByteArray?, + ): IncomingPayment { + return IncomingPayment( + preimage = ByteVector32(preimage), + origin = IncomingOriginData.deserialize(origin_type, origin_blob), + received = mapIncomingReceived(received_at, received_with_type, received_with_blob), + createdAt = created_at + ) + } + + private fun mapIncomingReceived( + received_at: Long?, + received_with_type: IncomingReceivedWithTypeVersion?, + received_with_blob: ByteArray?, + ): IncomingPayment.Received? { + return when { + received_at == null && received_with_type == null && received_with_blob == null -> null + received_at != null && received_with_type != null && received_with_blob != null -> { + IncomingPayment.Received( + receivedWith = IncomingReceivedWithData.deserialize(received_with_type, received_with_blob), + receivedAt = received_at + ) + } + received_at != null -> { + IncomingPayment.Received( + receivedWith = emptyList(), + receivedAt = received_at + ) + } + else -> throw UnreadableIncomingReceivedWith(received_at, received_with_type, received_with_blob) + } + } + + private fun mapTxIdPaymentHash( + tx_id: ByteArray, + payment_hash: ByteArray + ): Pair { + return tx_id.byteVector32() to payment_hash.byteVector32() + } + } +} +class IncomingPaymentNotFound(paymentHash: ByteVector32) : RuntimeException("missing payment for payment_hash=$paymentHash") +class UnreadableIncomingReceivedWith(receivedAt: Long?, receivedWithTypeVersion: IncomingReceivedWithTypeVersion?, receivedWithBlob: ByteArray?) : + RuntimeException("unreadable received with data [ receivedAt=$receivedAt, receivedWithTypeVersion=$receivedWithTypeVersion, receivedWithBlob=$receivedWithBlob ]") \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/IncomingReceivedWithType.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/IncomingReceivedWithType.kt new file mode 100644 index 0000000..9e58443 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/IncomingReceivedWithType.kt @@ -0,0 +1,169 @@ +/* + * Copyright 2021 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:UseSerializers( + SatoshiSerializer::class, + MilliSatoshiSerializer::class, + ByteVector32Serializer::class, + UUIDSerializer::class, +) + +package fr.acinq.lightning.bin.db.payments + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Satoshi +import fr.acinq.bitcoin.TxId +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.db.IncomingPayment +import fr.acinq.lightning.bin.db.serializers.v1.ByteVector32Serializer +import fr.acinq.lightning.bin.db.serializers.v1.MilliSatoshiSerializer +import fr.acinq.lightning.bin.db.serializers.v1.UUIDSerializer +import fr.acinq.lightning.bin.db.serializers.v1.SatoshiSerializer +import io.ktor.utils.io.charsets.* +import io.ktor.utils.io.core.* +import kotlinx.serialization.* +import kotlinx.serialization.builtins.SetSerializer + + +enum class IncomingReceivedWithTypeVersion { + MULTIPARTS_V1, +} + +sealed class IncomingReceivedWithData { + + @Serializable + sealed class Part : IncomingReceivedWithData() { + sealed class Htlc : Part() { + @Serializable + data class V0( + @Serializable val amount: MilliSatoshi, + @Serializable val channelId: ByteVector32, + val htlcId: Long + ) : Htlc() + } + + sealed class NewChannel : Part() { + /** V2 supports dual funding. New fields: service/miningFees, channel id, funding tx id, and the confirmation/lock timestamps. Id is removed. */ + @Serializable + data class V2( + @Serializable val amount: MilliSatoshi, + @Serializable val serviceFee: MilliSatoshi, + @Serializable val miningFee: Satoshi, + @Serializable val channelId: ByteVector32, + @Serializable val txId: ByteVector32, + @Serializable val confirmedAt: Long?, + @Serializable val lockedAt: Long?, + ) : NewChannel() + } + + sealed class SpliceIn : Part() { + @Serializable + data class V0( + @Serializable val amount: MilliSatoshi, + @Serializable val serviceFee: MilliSatoshi, + @Serializable val miningFee: Satoshi, + @Serializable val channelId: ByteVector32, + @Serializable val txId: ByteVector32, + @Serializable val confirmedAt: Long?, + @Serializable val lockedAt: Long?, + ) : SpliceIn() + } + + sealed class FeeCredit : Part() { + @Serializable + data class V0( + val amount: MilliSatoshi + ) : FeeCredit() + } + } + + companion object { + /** Deserializes a received-with blob from the database using the given [typeVersion]. */ + fun deserialize( + typeVersion: IncomingReceivedWithTypeVersion, + blob: ByteArray, + ): List = DbTypesHelper.decodeBlob(blob) { json, _ -> + when (typeVersion) { + IncomingReceivedWithTypeVersion.MULTIPARTS_V1 -> DbTypesHelper.polymorphicFormat.decodeFromString(SetSerializer(PolymorphicSerializer(Part::class)), json).map { + when (it) { + is Part.Htlc.V0 -> IncomingPayment.ReceivedWith.LightningPayment( + amount = it.amount, + channelId = it.channelId, + htlcId = it.htlcId + ) + is Part.NewChannel.V2 -> IncomingPayment.ReceivedWith.NewChannel( + amount = it.amount, + serviceFee = it.serviceFee, + miningFee = it.miningFee, + channelId = it.channelId, + txId = TxId(it.txId), + confirmedAt = it.confirmedAt, + lockedAt = it.lockedAt, + ) + is Part.SpliceIn.V0 -> IncomingPayment.ReceivedWith.SpliceIn( + amount = it.amount, + serviceFee = it.serviceFee, + miningFee = it.miningFee, + channelId = it.channelId, + txId = TxId(it.txId), + confirmedAt = it.confirmedAt, + lockedAt = it.lockedAt, + ) + is Part.FeeCredit.V0 -> IncomingPayment.ReceivedWith.FeeCreditPayment( + amount = it.amount + ) + } + } + } + } + } +} + +/** Only serialize received_with into the [IncomingReceivedWithTypeVersion.MULTIPARTS_V1] type. */ +fun List.mapToDb(): Pair? = map { + when (it) { + is IncomingPayment.ReceivedWith.LightningPayment -> IncomingReceivedWithData.Part.Htlc.V0( + amount = it.amount, + channelId = it.channelId, + htlcId = it.htlcId + ) + is IncomingPayment.ReceivedWith.NewChannel -> IncomingReceivedWithData.Part.NewChannel.V2( + amount = it.amount, + serviceFee = it.serviceFee, + miningFee = it.miningFee, + channelId = it.channelId, + txId = it.txId.value, + confirmedAt = it.confirmedAt, + lockedAt = it.lockedAt, + ) + is IncomingPayment.ReceivedWith.SpliceIn -> IncomingReceivedWithData.Part.SpliceIn.V0( + amount = it.amount, + serviceFee = it.serviceFee, + miningFee = it.miningFee, + channelId = it.channelId, + txId = it.txId.value, + confirmedAt = it.confirmedAt, + lockedAt = it.lockedAt, + ) + is IncomingPayment.ReceivedWith.FeeCreditPayment -> IncomingReceivedWithData.Part.FeeCredit.V0( + amount = it.amount + ) + } +}.takeIf { it.isNotEmpty() }?.toSet()?.let { + IncomingReceivedWithTypeVersion.MULTIPARTS_V1 to DbTypesHelper.polymorphicFormat.encodeToString( + SetSerializer(PolymorphicSerializer(IncomingReceivedWithData.Part::class)), it + ).toByteArray(Charsets.UTF_8) +} diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/LinkTxToPaymentQueries.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/LinkTxToPaymentQueries.kt new file mode 100644 index 0000000..43062b2 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/LinkTxToPaymentQueries.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2023 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.lightning.bin.db.payments + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import fr.acinq.bitcoin.TxId +import fr.acinq.lightning.bin.db.WalletPaymentId +import fr.acinq.phoenix.db.PaymentsDatabase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.* + +class LinkTxToPaymentQueries(val database: PaymentsDatabase) { + private val linkTxQueries = database.linkTxToPaymentQueries + + fun listUnconfirmedTxs(): Flow> { + return linkTxQueries.listUnconfirmed().asFlow().mapToList(Dispatchers.IO) + } + + fun listWalletPaymentIdsForTx(txId: TxId): List { + return linkTxQueries.getPaymentIdForTx(tx_id = txId.value.toByteArray()).executeAsList() + .mapNotNull { WalletPaymentId.create(it.type, it.id) } + } + + fun linkTxToPayment(txId: TxId, walletPaymentId: WalletPaymentId) { + linkTxQueries.linkTxToPayment(tx_id = txId.value.toByteArray(), type = walletPaymentId.dbType.value, id = walletPaymentId.dbId) + } + + fun setConfirmed(txId: TxId, confirmedAt: Long) { + linkTxQueries.setConfirmed(tx_id = txId.value.toByteArray(), confirmed_at = confirmedAt) + } + + fun setLocked(txId: TxId, lockedAt: Long) { + linkTxQueries.setLocked(tx_id = txId.value.toByteArray(), locked_at = lockedAt) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/OutgoingDetailsType.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/OutgoingDetailsType.kt new file mode 100644 index 0000000..a6b5029 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/OutgoingDetailsType.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2021 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:UseSerializers( + SatoshiSerializer::class, + ByteVector32Serializer::class, +) + +package fr.acinq.lightning.bin.db.payments + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.bin.db.serializers.v1.ByteVector32Serializer +import fr.acinq.lightning.bin.db.serializers.v1.SatoshiSerializer +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.payment.Bolt11Invoice +import io.ktor.utils.io.charsets.* +import io.ktor.utils.io.core.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + + +enum class OutgoingDetailsTypeVersion { + NORMAL_V0, + KEYSEND_V0, + SWAPOUT_V0, +} + +sealed class OutgoingDetailsData { + + sealed class Normal : OutgoingDetailsData() { + @Serializable + data class V0(val paymentRequest: String) : Normal() + } + + sealed class KeySend : OutgoingDetailsData() { + @Serializable + data class V0(@Serializable val preimage: ByteVector32) : KeySend() + } + + sealed class SwapOut : OutgoingDetailsData() { + @Serializable + data class V0(val address: String, val paymentRequest: String, @Serializable val swapOutFee: Satoshi) : SwapOut() + } + + companion object { + /** Deserialize the details of an outgoing payment. Return null if the details is for a legacy channel closing payment (see [deserializeLegacyClosingDetails]). */ + fun deserialize(typeVersion: OutgoingDetailsTypeVersion, blob: ByteArray): LightningOutgoingPayment.Details? = DbTypesHelper.decodeBlob(blob) { json, format -> + when (typeVersion) { + OutgoingDetailsTypeVersion.NORMAL_V0 -> format.decodeFromString(json).let { LightningOutgoingPayment.Details.Normal(Bolt11Invoice.read(it.paymentRequest).get()) } + OutgoingDetailsTypeVersion.KEYSEND_V0 -> format.decodeFromString(json).let { LightningOutgoingPayment.Details.KeySend(it.preimage) } + OutgoingDetailsTypeVersion.SWAPOUT_V0 -> format.decodeFromString(json).let { LightningOutgoingPayment.Details.SwapOut(it.address, Bolt11Invoice.read(it.paymentRequest).get(), it.swapOutFee) } + } + } + } +} + +fun LightningOutgoingPayment.Details.mapToDb(): Pair = when (this) { + is LightningOutgoingPayment.Details.Normal -> OutgoingDetailsTypeVersion.NORMAL_V0 to + Json.encodeToString(OutgoingDetailsData.Normal.V0(paymentRequest.write())).toByteArray(Charsets.UTF_8) + is LightningOutgoingPayment.Details.KeySend -> OutgoingDetailsTypeVersion.KEYSEND_V0 to + Json.encodeToString(OutgoingDetailsData.KeySend.V0(preimage)).toByteArray(Charsets.UTF_8) + is LightningOutgoingPayment.Details.SwapOut -> OutgoingDetailsTypeVersion.SWAPOUT_V0 to + Json.encodeToString(OutgoingDetailsData.SwapOut.V0(address, paymentRequest.write(), swapOutFee)).toByteArray(Charsets.UTF_8) +} diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/OutgoingPartClosingType.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/OutgoingPartClosingType.kt new file mode 100644 index 0000000..4ba01bf --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/OutgoingPartClosingType.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2021 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.lightning.bin.db.payments + +import fr.acinq.lightning.db.ChannelCloseOutgoingPayment +import fr.acinq.lightning.db.ChannelClosingType +import io.ktor.utils.io.charsets.* +import io.ktor.utils.io.core.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + + +enum class OutgoingPartClosingInfoTypeVersion { + // basic type, containing only a [ChannelClosingType] field + CLOSING_INFO_V0, +} + +sealed class OutgoingPartClosingInfoData { + + @Serializable + data class V0(val closingType: ChannelClosingType) + + companion object { + fun deserialize(typeVersion: OutgoingPartClosingInfoTypeVersion, blob: ByteArray): ChannelClosingType = DbTypesHelper.decodeBlob(blob) { json, format -> + when (typeVersion) { + OutgoingPartClosingInfoTypeVersion.CLOSING_INFO_V0 -> format.decodeFromString(json).closingType + } + } + } +} + +fun ChannelCloseOutgoingPayment.mapClosingTypeToDb() = OutgoingPartClosingInfoTypeVersion.CLOSING_INFO_V0 to + Json.encodeToString(OutgoingPartClosingInfoData.V0(this.closingType)).toByteArray(Charsets.UTF_8) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/OutgoingPartStatusType.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/OutgoingPartStatusType.kt new file mode 100644 index 0000000..801a489 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/OutgoingPartStatusType.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2021 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:UseSerializers( + ByteVector32Serializer::class, +) + +package fr.acinq.lightning.bin.db.payments + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.lightning.bin.db.serializers.v1.ByteVector32Serializer +import fr.acinq.lightning.db.LightningOutgoingPayment +import io.ktor.utils.io.charsets.* +import io.ktor.utils.io.core.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + + +enum class OutgoingPartStatusTypeVersion { + SUCCEEDED_V0, + FAILED_V0, +} + +sealed class OutgoingPartStatusData { + + sealed class Succeeded : OutgoingPartStatusData() { + @Serializable + data class V0(@Serializable val preimage: ByteVector32) : Succeeded() + } + + sealed class Failed : OutgoingPartStatusData() { + @Serializable + data class V0(val remoteFailureCode: Int?, val details: String) : Failed() + } + + companion object { + fun deserialize( + typeVersion: OutgoingPartStatusTypeVersion, + blob: ByteArray, completedAt: Long + ): LightningOutgoingPayment.Part.Status = DbTypesHelper.decodeBlob(blob) { json, format -> + when (typeVersion) { + OutgoingPartStatusTypeVersion.SUCCEEDED_V0 -> format.decodeFromString(json).let { + LightningOutgoingPayment.Part.Status.Succeeded(it.preimage, completedAt) + } + OutgoingPartStatusTypeVersion.FAILED_V0 -> format.decodeFromString(json).let { + LightningOutgoingPayment.Part.Status.Failed(it.remoteFailureCode, it.details, completedAt) + } + } + } + } +} + +fun LightningOutgoingPayment.Part.Status.Succeeded.mapToDb() = OutgoingPartStatusTypeVersion.SUCCEEDED_V0 to + Json.encodeToString(OutgoingPartStatusData.Succeeded.V0(preimage)).toByteArray(Charsets.UTF_8) + +fun LightningOutgoingPayment.Part.Status.Failed.mapToDb() = OutgoingPartStatusTypeVersion.FAILED_V0 to + Json.encodeToString(OutgoingPartStatusData.Failed.V0(remoteFailureCode, details)).toByteArray(Charsets.UTF_8) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/OutgoingQueries.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/OutgoingQueries.kt new file mode 100644 index 0000000..2b2db72 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/OutgoingQueries.kt @@ -0,0 +1,373 @@ +/* + * Copyright 2021 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.lightning.bin.db.payments + +import app.cash.sqldelight.ColumnAdapter +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PublicKey +import fr.acinq.bitcoin.utils.Either +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.ShortChannelId +import fr.acinq.lightning.channel.ChannelException +import fr.acinq.lightning.db.ChannelCloseOutgoingPayment +import fr.acinq.lightning.db.HopDesc +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.db.OutgoingPayment +import fr.acinq.lightning.payment.OutgoingPaymentFailure +import fr.acinq.lightning.utils.* +import fr.acinq.lightning.wire.FailureMessage +import fr.acinq.phoenix.db.PaymentsDatabase +import fr.acinq.secp256k1.Hex + +class OutgoingQueries(val database: PaymentsDatabase) { + + private val queries = database.outgoingPaymentsQueries + + fun addLightningParts(parentId: UUID, parts: List) { + if (parts.isEmpty()) return + database.transaction { + parts.map { + // This will throw an exception if the sqlite foreign-key-constraint is violated. + queries.insertLightningPart( + part_id = it.id.toString(), + part_parent_id = parentId.toString(), + part_amount_msat = it.amount.msat, + part_route = it.route, + part_created_at = it.createdAt + ) + } + } + } + + fun addLightningOutgoingPayment(payment: LightningOutgoingPayment) { + val (detailsTypeVersion, detailsData) = payment.details.mapToDb() + database.transaction(noEnclosing = false) { + queries.insertPayment( + id = payment.id.toString(), + recipient_amount_msat = payment.recipientAmount.msat, + recipient_node_id = payment.recipient.toString(), + payment_hash = payment.details.paymentHash.toByteArray(), + created_at = payment.createdAt, + details_type = detailsTypeVersion, + details_blob = detailsData + ) + payment.parts.map { + queries.insertLightningPart( + part_id = it.id.toString(), + part_parent_id = payment.id.toString(), + part_amount_msat = it.amount.msat, + part_route = it.route, + part_created_at = it.createdAt + ) + } + } + } + + fun completePayment(id: UUID, completed: LightningOutgoingPayment.Status.Completed): Boolean { + var result = true + database.transaction { + val (statusType, statusBlob) = completed.mapToDb() + queries.updatePayment( + id = id.toString(), + completed_at = completed.completedAt, + status_type = statusType, + status_blob = statusBlob + ) + if (queries.changes().executeAsOne() != 1L) { + result = false + } + } + return result + } + + fun updateLightningPart( + partId: UUID, + preimage: ByteVector32, + completedAt: Long + ): Boolean { + var result = true + val (statusTypeVersion, statusData) = LightningOutgoingPayment.Part.Status.Succeeded(preimage).mapToDb() + database.transaction { + queries.updateLightningPart( + part_id = partId.toString(), + part_status_type = statusTypeVersion, + part_status_blob = statusData, + part_completed_at = completedAt + ) + if (queries.changes().executeAsOne() != 1L) { + result = false + } + } + return result + } + + fun updateLightningPart( + partId: UUID, + failure: Either, + completedAt: Long + ): Boolean { + var result = true + val (statusTypeVersion, statusData) = OutgoingPaymentFailure.convertFailure(failure).mapToDb() + database.transaction { + queries.updateLightningPart( + part_id = partId.toString(), + part_status_type = statusTypeVersion, + part_status_blob = statusData, + part_completed_at = completedAt + ) + if (queries.changes().executeAsOne() != 1L) { + result = false + } + } + return result + } + + /** This method will ignore any parts that are not proper [LightningOutgoingPayment]. */ + fun getPaymentFromPartId(partId: UUID): LightningOutgoingPayment? { + return queries.getLightningPart(part_id = partId.toString()).executeAsOneOrNull()?.let { part -> + queries.getPayment(id = part.part_parent_id, Companion::mapLightningOutgoingPayment).executeAsList() + }?.filterIsInstance()?.let { + // first ignore any legacy channel closing, then group by parent id + groupByRawLightningOutgoing(it).firstOrNull() + }?.let { + filterUselessParts(it) + // resulting payment must contain the request part id, or should be null + .takeIf { p -> p.parts.map { it.id }.contains(partId) } + } + } + + fun getPaymentWithoutParts(id: UUID): LightningOutgoingPayment? { + return queries.getPaymentWithoutParts( + id = id.toString(), + mapper = Companion::mapLightningOutgoingPaymentWithoutParts + ).executeAsOneOrNull() + } + + /** + * Returns a [LightningOutgoingPayment] for this id - if instead we find legacy converted to a new type (such as + * [ChannelCloseOutgoingPayment], this payment is ignored and we return null instead. + */ + fun getPaymentStrict(id: UUID): LightningOutgoingPayment? = queries.getPayment( + id = id.toString(), + mapper = Companion::mapLightningOutgoingPayment + ).executeAsList().let { parts -> + // only take regular LN payments parts, and group them + parts.filterIsInstance().let { + groupByRawLightningOutgoing(it).firstOrNull() + }?.let { + filterUselessParts(it) + } + } + + /** + * May return a [ChannelCloseOutgoingPayment] instead of the expected [LightningOutgoingPayment]. That's because + * channel closing used to be stored as [LightningOutgoingPayment] with special closing parts. We convert those to + * the propert object type. + */ + fun getPaymentRelaxed(id: UUID): OutgoingPayment? = queries.getPayment( + id = id.toString(), + mapper = Companion::mapLightningOutgoingPayment + ).executeAsList().let { parts -> + // this payment may be a legacy channel closing - otherwise, only take regular LN payment parts, and group them + parts.firstOrNull { it is ChannelCloseOutgoingPayment } ?: parts.filterIsInstance().let { + groupByRawLightningOutgoing(it).firstOrNull() + }?.let { + filterUselessParts(it) + } + } + + fun getOldestCompletedDate(): Long? { + return queries.getOldestCompletedDate().executeAsOneOrNull() + } + + fun listLightningOutgoingPayments(paymentHash: ByteVector32): List { + return queries.listPaymentsForPaymentHash(paymentHash.toByteArray(), Companion::mapLightningOutgoingPayment).executeAsList() + .filterIsInstance() + .let { groupByRawLightningOutgoing(it) } + } + + /** Group a list of outgoing payments by parent id and parts. */ + private fun groupByRawLightningOutgoing(payments: List) = payments + .takeIf { it.isNotEmpty() } + ?.groupBy { it.id } + ?.values + ?.map { group -> group.first().copy(parts = group.flatMap { it.parts }) } + ?: emptyList() + + /** Get a payment without its failed/pending parts. */ + private fun filterUselessParts(payment: LightningOutgoingPayment): LightningOutgoingPayment = when (payment.status) { + is LightningOutgoingPayment.Status.Completed.Succeeded.OffChain -> { + payment.copy(parts = payment.parts.filter { + it.status is LightningOutgoingPayment.Part.Status.Succeeded + }) + } + else -> payment + } + + companion object { + @Suppress("UNUSED_PARAMETER") + fun mapLightningOutgoingPaymentWithoutParts( + id: String, + recipient_amount_msat: Long, + recipient_node_id: String, + payment_hash: ByteArray, + details_type: OutgoingDetailsTypeVersion, + details_blob: ByteArray, + created_at: Long, + completed_at: Long?, + status_type: OutgoingStatusTypeVersion?, + status_blob: ByteArray? + ): LightningOutgoingPayment { + val details = OutgoingDetailsData.deserialize(details_type, details_blob) + return if (details != null) { + LightningOutgoingPayment( + id = UUID.fromString(id), + recipientAmount = MilliSatoshi(recipient_amount_msat), + recipient = PublicKey.parse(Hex.decode(recipient_node_id)), + details = details, + parts = listOf(), + status = mapPaymentStatus(status_type, status_blob, completed_at), + createdAt = created_at + ) + } else throw IllegalArgumentException("cannot handle closing payment at this stage, use LegacyChannelCloseHelper") + } + + @Suppress("UNUSED_PARAMETER") + fun mapLightningOutgoingPayment( + id: String, + recipient_amount_msat: Long, + recipient_node_id: String, + payment_hash: ByteArray, + details_type: OutgoingDetailsTypeVersion, + details_blob: ByteArray, + created_at: Long, + completed_at: Long?, + status_type: OutgoingStatusTypeVersion?, + status_blob: ByteArray?, + // lightning parts data, may be null + lightning_part_id: String?, + lightning_part_amount_msat: Long?, + lightning_part_route: List?, + lightning_part_created_at: Long?, + lightning_part_completed_at: Long?, + lightning_part_status_type: OutgoingPartStatusTypeVersion?, + lightning_part_status_blob: ByteArray?, + // closing tx parts data, may be null + closingtx_part_id: String?, + closingtx_part_tx_id: ByteArray?, + closingtx_part_amount_sat: Long?, + closingtx_part_closing_info_type: OutgoingPartClosingInfoTypeVersion?, + closingtx_part_closing_info_blob: ByteArray?, + closingtx_part_created_at: Long? + ): OutgoingPayment { + + val parts = if (lightning_part_id != null && lightning_part_amount_msat != null && lightning_part_route != null && lightning_part_created_at != null) { + listOf( + mapLightningPart( + id = lightning_part_id, + amountMsat = lightning_part_amount_msat, + route = lightning_part_route, + createdAt = lightning_part_created_at, + completedAt = lightning_part_completed_at, + statusType = lightning_part_status_type, + statusBlob = lightning_part_status_blob + ) + ) + } else emptyList() + + return mapLightningOutgoingPaymentWithoutParts( + id = id, + recipient_amount_msat = recipient_amount_msat, + recipient_node_id = recipient_node_id, + payment_hash = payment_hash, + details_type = details_type, + details_blob = details_blob, + created_at = created_at, + completed_at = completed_at, + status_type = status_type, + status_blob = status_blob + ).copy( + parts = parts + ) + } + + private fun mapLightningPart( + id: String, + amountMsat: Long, + route: List, + createdAt: Long, + completedAt: Long?, + statusType: OutgoingPartStatusTypeVersion?, + statusBlob: ByteArray? + ): LightningOutgoingPayment.Part { + return LightningOutgoingPayment.Part( + id = UUID.fromString(id), + amount = MilliSatoshi(amountMsat), + route = route, + status = mapLightningPartStatus( + statusType = statusType, + statusBlob = statusBlob, + completedAt = completedAt + ), + createdAt = createdAt + ) + } + + private fun mapPaymentStatus( + statusType: OutgoingStatusTypeVersion?, + statusBlob: ByteArray?, + completedAt: Long?, + ): LightningOutgoingPayment.Status = when { + completedAt == null && statusType == null && statusBlob == null -> LightningOutgoingPayment.Status.Pending + completedAt != null && statusType != null && statusBlob != null -> OutgoingStatusData.deserialize(statusType, statusBlob, completedAt) + else -> throw UnhandledOutgoingStatus(completedAt, statusType, statusBlob) + } + + private fun mapLightningPartStatus( + statusType: OutgoingPartStatusTypeVersion?, + statusBlob: ByteArray?, + completedAt: Long?, + ): LightningOutgoingPayment.Part.Status = when { + completedAt == null && statusType == null && statusBlob == null -> LightningOutgoingPayment.Part.Status.Pending + completedAt != null && statusType != null && statusBlob != null -> OutgoingPartStatusData.deserialize(statusType, statusBlob, completedAt) + else -> throw UnhandledOutgoingPartStatus(statusType, statusBlob, completedAt) + } + + val hopDescAdapter: ColumnAdapter, String> = object : ColumnAdapter, String> { + override fun decode(databaseValue: String): List = when { + databaseValue.isEmpty() -> listOf() + else -> databaseValue.split(";").map { hop -> + val els = hop.split(":") + val n1 = PublicKey.parse(Hex.decode(els[0])) + val n2 = PublicKey.parse(Hex.decode(els[1])) + val cid = els[2].takeIf { it.isNotBlank() }?.run { ShortChannelId(this) } + HopDesc(n1, n2, cid) + } + } + + override fun encode(value: List): String = value.joinToString(";") { + "${it.nodeId}:${it.nextNodeId}:${it.shortChannelId ?: ""}" + } + } + } +} + +data class UnhandledOutgoingStatus(val completedAt: Long?, val statusTypeVersion: OutgoingStatusTypeVersion?, val statusData: ByteArray?) : + RuntimeException("cannot map outgoing payment status data with completed_at=$completedAt status_type=$statusTypeVersion status=$statusData") + +data class UnhandledOutgoingPartStatus(val status_type: OutgoingPartStatusTypeVersion?, val status_blob: ByteArray?, val completedAt: Long?) : + RuntimeException("cannot map outgoing part status data [ completed_at=$completedAt status_type=$status_type status_blob=$status_blob]") \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/OutgoingStatusType.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/OutgoingStatusType.kt new file mode 100644 index 0000000..d44ef4d --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/OutgoingStatusType.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2021 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:UseSerializers( + SatoshiSerializer::class, + ByteVector32Serializer::class, +) + +package fr.acinq.lightning.bin.db.payments + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.Satoshi +import fr.acinq.lightning.bin.db.payments.DbTypesHelper.decodeBlob +import fr.acinq.lightning.bin.db.serializers.v1.ByteVector32Serializer +import fr.acinq.lightning.bin.db.serializers.v1.SatoshiSerializer +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.payment.FinalFailure +import io.ktor.utils.io.charsets.* +import io.ktor.utils.io.core.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +enum class OutgoingStatusTypeVersion { + SUCCEEDED_OFFCHAIN_V0, + FAILED_V0, +} + +sealed class OutgoingStatusData { + + sealed class SucceededOffChain : OutgoingStatusData() { + @Serializable + data class V0(@Serializable val preimage: ByteVector32) : SucceededOffChain() + } + + sealed class SucceededOnChain : OutgoingStatusData() { + @Serializable + data class V0( + val txIds: List<@Serializable ByteVector32>, + @Serializable val claimed: Satoshi, + val closingType: String + ) : SucceededOnChain() + + @Serializable + object V1 : SucceededOnChain() + } + + sealed class Failed : OutgoingStatusData() { + @Serializable + data class V0(val reason: String) : Failed() + } + + companion object { + + /** Extract valuable data from old outgoing payments status that represent closing transactions. */ + fun deserializeLegacyClosingStatus(blob: ByteArray): SucceededOnChain.V0 = decodeBlob(blob) { json, format -> + val data = format.decodeFromString(json) + data + } + + fun deserialize(typeVersion: OutgoingStatusTypeVersion, blob: ByteArray, completedAt: Long): LightningOutgoingPayment.Status = decodeBlob(blob) { json, format -> + @Suppress("DEPRECATION") + when (typeVersion) { + OutgoingStatusTypeVersion.SUCCEEDED_OFFCHAIN_V0 -> format.decodeFromString(json).let { + LightningOutgoingPayment.Status.Completed.Succeeded.OffChain(it.preimage, completedAt) + } + OutgoingStatusTypeVersion.FAILED_V0 -> format.decodeFromString(json).let { + LightningOutgoingPayment.Status.Completed.Failed(deserializeFinalFailure(it.reason), completedAt) + } + } + } + + internal fun serializeFinalFailure(failure: FinalFailure): String = failure::class.simpleName ?: "UnknownError" + + private fun deserializeFinalFailure(failure: String): FinalFailure = when (failure) { + FinalFailure.InvalidPaymentAmount::class.simpleName -> FinalFailure.InvalidPaymentAmount + FinalFailure.InvalidPaymentId::class.simpleName -> FinalFailure.InvalidPaymentId + FinalFailure.NoAvailableChannels::class.simpleName -> FinalFailure.NoAvailableChannels + FinalFailure.InsufficientBalance::class.simpleName -> FinalFailure.InsufficientBalance + FinalFailure.NoRouteToRecipient::class.simpleName -> FinalFailure.NoRouteToRecipient + FinalFailure.RecipientUnreachable::class.simpleName -> FinalFailure.RecipientUnreachable + FinalFailure.RetryExhausted::class.simpleName -> FinalFailure.RetryExhausted + FinalFailure.WalletRestarted::class.simpleName -> FinalFailure.WalletRestarted + else -> FinalFailure.UnknownError + } + } +} + +fun LightningOutgoingPayment.Status.Completed.mapToDb(): Pair = when (this) { + is LightningOutgoingPayment.Status.Completed.Succeeded.OffChain -> OutgoingStatusTypeVersion.SUCCEEDED_OFFCHAIN_V0 to + Json.encodeToString(OutgoingStatusData.SucceededOffChain.V0(preimage)).toByteArray(Charsets.UTF_8) + is LightningOutgoingPayment.Status.Completed.Failed -> OutgoingStatusTypeVersion.FAILED_V0 to + Json.encodeToString(OutgoingStatusData.Failed.V0(OutgoingStatusData.serializeFinalFailure(reason))).toByteArray(Charsets.UTF_8) +} + diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/SpliceCpfpOutgoingQueries.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/SpliceCpfpOutgoingQueries.kt new file mode 100644 index 0000000..b675991 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/SpliceCpfpOutgoingQueries.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2023 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.lightning.bin.db.payments + +import fr.acinq.bitcoin.TxId +import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.utils.toByteVector32 +import fr.acinq.phoenix.db.PaymentsDatabase + +class SpliceCpfpOutgoingQueries(val database: PaymentsDatabase) { + private val cpfpQueries = database.spliceCpfpOutgoingPaymentsQueries + + fun addCpfpPayment(payment: SpliceCpfpOutgoingPayment) { + database.transaction { + cpfpQueries.insertCpfp( + id = payment.id.toString(), + mining_fees_sat = payment.miningFees.sat, + channel_id = payment.channelId.toByteArray(), + tx_id = payment.txId.value.toByteArray(), + created_at = payment.createdAt, + confirmed_at = payment.confirmedAt, + locked_at = payment.lockedAt + ) + } + } + + fun getCpfp(id: UUID): SpliceCpfpOutgoingPayment? { + return cpfpQueries.getCpfp( + id = id.toString(), + mapper = Companion::mapCpfp + ).executeAsOneOrNull() + } + + fun setConfirmed(id: UUID, confirmedAt: Long) { + database.transaction { + cpfpQueries.setConfirmed(confirmed_at = confirmedAt, id = id.toString()) + } + } + + fun setLocked(id: UUID, lockedAt: Long) { + database.transaction { + cpfpQueries.setLocked(locked_at = lockedAt, id = id.toString()) + } + } + + private companion object { + fun mapCpfp( + id: String, + mining_fees_sat: Long, + channel_id: ByteArray, + tx_id: ByteArray, + created_at: Long, + confirmed_at: Long?, + locked_at: Long? + ): SpliceCpfpOutgoingPayment { + return SpliceCpfpOutgoingPayment( + id = UUID.fromString(id), + miningFees = mining_fees_sat.sat, + channelId = channel_id.toByteVector32(), + txId = TxId(tx_id), + createdAt = created_at, + confirmedAt = confirmed_at, + lockedAt = locked_at + ) + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/SpliceOutgoingQueries.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/SpliceOutgoingQueries.kt new file mode 100644 index 0000000..7d8c632 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/payments/SpliceOutgoingQueries.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2023 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.lightning.bin.db.payments + +import fr.acinq.bitcoin.TxId +import fr.acinq.lightning.db.SpliceOutgoingPayment +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.utils.toByteVector32 +import fr.acinq.phoenix.db.PaymentsDatabase + +class SpliceOutgoingQueries(val database: PaymentsDatabase) { + private val spliceOutQueries = database.spliceOutgoingPaymentsQueries + + fun addSpliceOutgoingPayment(payment: SpliceOutgoingPayment) { + database.transaction { + spliceOutQueries.insertSpliceOutgoing( + id = payment.id.toString(), + recipient_amount_sat = payment.recipientAmount.sat, + address = payment.address, + mining_fees_sat = payment.miningFees.sat, + channel_id = payment.channelId.toByteArray(), + tx_id = payment.txId.value.toByteArray(), + created_at = payment.createdAt, + confirmed_at = payment.confirmedAt, + locked_at = payment.lockedAt + ) + } + } + + fun getSpliceOutPayment(id: UUID): SpliceOutgoingPayment? { + return spliceOutQueries.getSpliceOutgoing( + id = id.toString(), + mapper = Companion::mapSpliceOutgoingPayment + ).executeAsOneOrNull() + } + + fun setConfirmed(id: UUID, confirmedAt: Long) { + database.transaction { + spliceOutQueries.setConfirmed(confirmed_at = confirmedAt, id = id.toString()) + } + } + + fun setLocked(id: UUID, lockedAt: Long) { + database.transaction { + spliceOutQueries.setLocked(locked_at = lockedAt, id = id.toString()) + } + } + + companion object { + fun mapSpliceOutgoingPayment( + id: String, + recipient_amount_sat: Long, + address: String, + mining_fees_sat: Long, + tx_id: ByteArray, + channel_id: ByteArray, + created_at: Long, + confirmed_at: Long?, + locked_at: Long? + ): SpliceOutgoingPayment { + return SpliceOutgoingPayment( + id = UUID.fromString(id), + recipientAmount = recipient_amount_sat.sat, + address = address, + miningFees = mining_fees_sat.sat, + txId = TxId(tx_id), + channelId = channel_id.toByteVector32(), + createdAt = created_at, + confirmedAt = confirmed_at, + lockedAt = locked_at + ) + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/AbstractStringSerializer.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/AbstractStringSerializer.kt new file mode 100644 index 0000000..369e0c4 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/AbstractStringSerializer.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.lightning.bin.db.serializers.v1 + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +abstract class AbstractStringSerializer( + name: String, + private val toString: (T) -> String, + private val fromString: (String) -> T +) : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(name, PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: T) { + encoder.encodeString(toString(value)) + } + + override fun deserialize(decoder: Decoder): T { + return fromString(decoder.decodeString()) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/ByteVectorSerializer.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/ByteVectorSerializer.kt new file mode 100644 index 0000000..9d2f793 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/ByteVectorSerializer.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.lightning.bin.db.serializers.v1 + +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.ByteVector64 +import fr.acinq.lightning.bin.db.serializers.v1.AbstractStringSerializer + + +object ByteVector32Serializer : AbstractStringSerializer( + name = "ByteVector32", + toString = ByteVector32::toHex, + fromString = ::ByteVector32 +) + +object ByteVector64Serializer : AbstractStringSerializer( + name = "ByteVector64", + toString = ByteVector64::toHex, + fromString = ::ByteVector64 +) + +object ByteVectorSerializer : AbstractStringSerializer( + name = "ByteVector", + toString = ByteVector::toHex, + fromString = ::ByteVector +) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/MilliSatoshiSerializer.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/MilliSatoshiSerializer.kt new file mode 100644 index 0000000..4dbac14 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/MilliSatoshiSerializer.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.lightning.bin.db.serializers.v1 + +import fr.acinq.lightning.MilliSatoshi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object MilliSatoshiSerializer : KSerializer { + // we are using a surrogate for legacy reasons. + @Serializable + private data class MilliSatoshiSurrogate(val msat: Long) + + override val descriptor: SerialDescriptor = MilliSatoshiSurrogate.serializer().descriptor + + override fun serialize(encoder: Encoder, value: MilliSatoshi) { + val surrogate = MilliSatoshiSurrogate(msat = value.msat) + return encoder.encodeSerializableValue(MilliSatoshiSurrogate.serializer(), surrogate) + } + + override fun deserialize(decoder: Decoder): MilliSatoshi { + val surrogate = decoder.decodeSerializableValue(MilliSatoshiSurrogate.serializer()) + return MilliSatoshi(msat = surrogate.msat) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/OutpointSerializer.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/OutpointSerializer.kt new file mode 100644 index 0000000..16a775a --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/OutpointSerializer.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.lightning.bin.db.serializers.v1 + +import fr.acinq.bitcoin.OutPoint +import fr.acinq.bitcoin.TxHash + +class OutpointSerializer : AbstractStringSerializer( + name = "Outpoint", + fromString = { serialized -> + serialized.split(":").let { + OutPoint(hash = TxHash(it[0]), index = it[1].toLong()) + } + }, + toString = { outpoint -> "${outpoint.hash}:${outpoint.index}" } +) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/SatoshiSerializer.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/SatoshiSerializer.kt new file mode 100644 index 0000000..1af0b9b --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/SatoshiSerializer.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.lightning.bin.db.serializers.v1 + +import fr.acinq.bitcoin.Satoshi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object SatoshiSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Satoshi", PrimitiveKind.LONG) + + override fun serialize(encoder: Encoder, value: Satoshi) { + encoder.encodeLong(value.toLong()) + } + + override fun deserialize(decoder: Decoder): Satoshi { + return Satoshi(decoder.decodeLong()) + } +} diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/UUIDSerializer.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/UUIDSerializer.kt new file mode 100644 index 0000000..bd60493 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/db/serializers/v1/UUIDSerializer.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.lightning.bin.db.serializers.v1 + +import fr.acinq.lightning.utils.UUID +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + + +object UUIDSerializer : KSerializer { + @Serializable + private data class UUIDSurrogate(val mostSignificantBits: Long, val leastSignificantBits: Long) + + override val descriptor: SerialDescriptor = UUIDSurrogate.serializer().descriptor + + override fun serialize(encoder: Encoder, value: UUID) { + val surrogate = UUIDSurrogate(value.mostSignificantBits, value.leastSignificantBits) + return encoder.encodeSerializableValue(UUIDSurrogate.serializer(), surrogate) + } + + override fun deserialize(decoder: Decoder): UUID { + val surrogate = decoder.decodeSerializableValue(UUIDSurrogate.serializer()) + return UUID(surrogate.mostSignificantBits, surrogate.leastSignificantBits) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt new file mode 100644 index 0000000..b45bb1f --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/json/JsonSerializers.kt @@ -0,0 +1,92 @@ +@file:UseSerializers( + // This is used by Kotlin at compile time to resolve serializers (defined in this file) + // in order to build serializers for other classes (also defined in this file). + // If we used @Serializable annotations directly on the actual classes, Kotlin would be + // able to resolve serializers by itself. It is verbose, but it allows us to contain + // serialization code in this file. + JsonSerializers.SatoshiSerializer::class, + JsonSerializers.MilliSatoshiSerializer::class, + JsonSerializers.ByteVector32Serializer::class, + JsonSerializers.PublicKeySerializer::class, + JsonSerializers.TxIdSerializer::class, +) + +package fr.acinq.lightning.bin.json + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PublicKey +import fr.acinq.bitcoin.Satoshi +import fr.acinq.bitcoin.TxId +import fr.acinq.lightning.channel.states.ChannelState +import fr.acinq.lightning.channel.states.ChannelStateWithCommitments +import fr.acinq.lightning.db.LightningOutgoingPayment +import fr.acinq.lightning.json.JsonSerializers +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers + +sealed class ApiType { + + @Serializable + data class Channel internal constructor( + val state: String, + val channelId: ByteVector32? = null, + val balanceSat: Satoshi? = null, + val inboundLiquiditySat: Satoshi? = null, + val capacitySat: Satoshi? = null, + val fundingTxId: TxId? = null + ) { + companion object { + fun from(channel: ChannelState) = when { + channel is ChannelStateWithCommitments -> Channel( + state = channel.stateName, + channelId = channel.channelId, + balanceSat = channel.commitments.availableBalanceForSend().truncateToSatoshi(), + inboundLiquiditySat = channel.commitments.availableBalanceForReceive().truncateToSatoshi(), + capacitySat = channel.commitments.active.first().fundingAmount, + fundingTxId = channel.commitments.active.first().fundingTxId + ) + else -> Channel(state = channel.stateName) + } + } + } + + @Serializable + data class NodeInfo( + val nodeId: PublicKey, + val channels: List + ) + + @Serializable + data class Balance(@SerialName("amountSat") val amount: Satoshi, @SerialName("feeCreditSat") val feeCredit: Satoshi) : ApiType() + + @Serializable + data class GeneratedInvoice(@SerialName("amountSat") val amount: Satoshi?, val paymentHash: ByteVector32, val serialized: String) : ApiType() + + @Serializable + sealed class ApiEvent : ApiType() + + @Serializable + @SerialName("payment_received") + data class PaymentReceived(@SerialName("amountSat") val amount: Satoshi, val paymentHash: ByteVector32) : ApiEvent() { + constructor(event: fr.acinq.lightning.PaymentEvents.PaymentReceived) : this(event.amount.truncateToSatoshi(), event.paymentHash) + } + + @Serializable + @SerialName("payment_sent") + data class PaymentSent(@SerialName("recipientAmountSat") val recipientAmount: Satoshi, @SerialName("routingFeeSat") val routingFee: Satoshi, val paymentHash: ByteVector32, val paymentPreimage: ByteVector32) : ApiEvent() { + constructor(event: fr.acinq.lightning.io.PaymentSent) : this( + event.payment.recipientAmount.truncateToSatoshi(), + event.payment.routingFee.truncateToSatoshi(), + event.payment.paymentHash, + (event.payment.status as LightningOutgoingPayment.Status.Completed.Succeeded.OffChain).preimage + ) + } + + @Serializable + @SerialName("payment_failed") + data class PaymentFailed(val paymentHash: ByteVector32, val reason: String) : ApiType() { + constructor(event: fr.acinq.lightning.io.PaymentNotSent) : this(event.request.paymentHash, event.reason.reason.toString()) + } + +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/bin/logs/FileLogWriter.kt b/src/commonMain/kotlin/fr/acinq/lightning/bin/logs/FileLogWriter.kt new file mode 100644 index 0000000..8a95b26 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/bin/logs/FileLogWriter.kt @@ -0,0 +1,32 @@ +package fr.acinq.lightning.bin.logs + +import co.touchlab.kermit.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.launch +import okio.FileSystem +import okio.Path +import okio.buffer + +class FileLogWriter(private val logFile: Path, scope: CoroutineScope, private val messageStringFormatter: MessageStringFormatter = DefaultFormatter) : LogWriter() { + private val mailbox: Channel = Channel(Channel.BUFFERED) + + override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { + mailbox.trySend(messageStringFormatter.formatMessage(severity, Tag(tag), Message(message))) + throwable?.run { mailbox.trySend(stackTraceToString()) } + } + + init { + scope.launch { + val sink = FileSystem.SYSTEM.appendingSink(logFile).buffer() + mailbox.consumeAsFlow().collect { logLine -> + val sb = StringBuilder() + sb.append(logLine) + sb.appendLine() + sink.writeUtf8(sb.toString()) + sink.flush() + } + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt b/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt new file mode 100644 index 0000000..954e05f --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/cli/PhoenixCli.kt @@ -0,0 +1,194 @@ +package fr.acinq.lightning.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.context +import com.github.ajalt.clikt.core.requireObject +import com.github.ajalt.clikt.core.subcommands +import com.github.ajalt.clikt.output.MordantHelpFormatter +import com.github.ajalt.clikt.parameters.options.* +import com.github.ajalt.clikt.parameters.types.int +import com.github.ajalt.clikt.parameters.types.long +import com.github.ajalt.clikt.sources.MapValueSource +import fr.acinq.bitcoin.Base58Check +import fr.acinq.bitcoin.Bech32 +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.lightning.bin.conf.readConfFile +import fr.acinq.lightning.bin.homeDirectory +import fr.acinq.lightning.payment.Bolt11Invoice +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.auth.* +import io.ktor.client.plugins.auth.providers.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.util.* +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json + +fun main(args: Array) = + PhoenixCli() + .subcommands(GetInfo(), GetBalance(), ListChannels(), CreateInvoice(), PayInvoice(), SendToAddress(), CloseChannel()) + .main(args) + +data class HttpConf(val baseUrl: Url, val httpClient: HttpClient) + +class PhoenixCli : CliktCommand() { + private val datadir = homeDirectory / ".phoenix" + private val confFile = datadir / "phoenix.conf" + + private val httpBindIp by option("--http-bind-ip", help = "Bind ip for the http api").default("127.0.0.1") + private val httpBindPort by option("--http-bind-port", help = "Bind port for the http api").int().default(9740) + private val httpPassword by option("--http-password", help = "Password for the http api").required() + + init { + context { + valueSource = MapValueSource(readConfFile(confFile)) + helpFormatter = { MordantHelpFormatter(it, showDefaultValues = true) } + } + } + + override fun run() { + currentContext.obj = HttpConf( + baseUrl = Url( + url { + protocol = URLProtocol.HTTP + host = httpBindIp + port = httpBindPort + } + ), + httpClient = HttpClient(CIO) { + install(ContentNegotiation) { + json(json = Json { + prettyPrint = true + isLenient = true + }) + } + install(Auth) { + basic { + credentials { + BasicAuthCredentials("phoenix-cli", httpPassword) + } + } + } + } + ) + } +} + +class GetInfo : CliktCommand(name = "getinfo", help = "Show basic info about your node") { + private val commonOptions by requireObject() + override fun run() { + runBlocking { + val res = commonOptions.httpClient.get( + url = commonOptions.baseUrl / "getinfo" + ) + echo(res.bodyAsText()) + } + } +} + +class GetBalance : CliktCommand(name = "getbalance", help = "Returns your current balance") { + private val commonOptions by requireObject() + override fun run() { + runBlocking { + val res = commonOptions.httpClient.get( + url = commonOptions.baseUrl / "getbalance" + ) + echo(res.bodyAsText()) + } + } +} + +class ListChannels : CliktCommand(name = "listchannels", help = "List all channels") { + private val commonOptions by requireObject() + override fun run() { + runBlocking { + val res = commonOptions.httpClient.get( + url = commonOptions.baseUrl / "listchannels" + ) + echo(res.bodyAsText()) + } + } +} + +class CreateInvoice : CliktCommand(name = "createinvoice", help = "Create a Lightning invoice", printHelpOnEmptyArgs = true) { + private val commonOptions by requireObject() + private val amountSat by option("--amountSat").long() + private val description by option("--description", "--desc").required() + override fun run() { + runBlocking { + val res = commonOptions.httpClient.submitForm( + url = (commonOptions.baseUrl / "createinvoice").toString(), + formParameters = parameters { + amountSat?.let { append("amountSat", amountSat.toString()) } + append("description", description) + } + ) + echo(res.bodyAsText()) + } + } +} + +class PayInvoice : CliktCommand(name = "payinvoice", help = "Pay a Lightning invoice", printHelpOnEmptyArgs = true) { + private val commonOptions by requireObject() + private val amountSat by option("--amountSat").long() + private val invoice by option("--invoice").required().check { Bolt11Invoice.read(it).isSuccess } + override fun run() { + runBlocking { + val res = commonOptions.httpClient.submitForm( + url = (commonOptions.baseUrl / "payinvoice").toString(), + formParameters = parameters { + amountSat?.let { append("amountSat", amountSat.toString()) } + append("invoice", invoice) + } + ) + echo(res.bodyAsText()) + } + } +} + +class SendToAddress : CliktCommand(name = "sendtoaddress", help = "Send to a Bitcoin address", printHelpOnEmptyArgs = true) { + private val commonOptions by requireObject() + private val amountSat by option("--amountSat").long().required() + private val address by option("--address").required().check { runCatching { Base58Check.decode(it) }.isSuccess || runCatching { Bech32.decodeWitnessAddress(it) }.isSuccess } + private val feerateSatByte by option("--feerateSatByte").int().required() + override fun run() { + runBlocking { + val res = commonOptions.httpClient.submitForm( + url = (commonOptions.baseUrl / "sendtoaddress").toString(), + formParameters = parameters { + append("amountSat", amountSat.toString()) + append("address", address) + append("feerateSatByte", feerateSatByte.toString()) + } + ) + echo(res.bodyAsText()) + } + } +} + +class CloseChannel : CliktCommand(name = "closechannel", help = "Close all channels", printHelpOnEmptyArgs = true) { + private val commonOptions by requireObject() + private val channelId by option("--channelId").convert { ByteVector32.fromValidHex(it) }.required() + private val address by option("--address").required().check { runCatching { Base58Check.decode(it) }.isSuccess || runCatching { Bech32.decodeWitnessAddress(it) }.isSuccess } + private val feerateSatByte by option("--feerateSatByte").int().required() + override fun run() { + runBlocking { + val res = commonOptions.httpClient.submitForm( + url = (commonOptions.baseUrl / "closechannel").toString(), + formParameters = parameters { + append("channelId", channelId.toHex()) + append("address", address) + append("feerateSatByte", feerateSatByte.toString()) + } + ) + echo(res.bodyAsText()) + } + } +} + +operator fun Url.div(path: String) = Url(URLBuilder(this).appendPathSegments(path)) \ No newline at end of file diff --git a/src/commonMain/sqldelight/channelsdb/fr/acinq/phoenix/db/ChannelsDatabase.sq b/src/commonMain/sqldelight/channelsdb/fr/acinq/phoenix/db/ChannelsDatabase.sq new file mode 100644 index 0000000..222f689 --- /dev/null +++ b/src/commonMain/sqldelight/channelsdb/fr/acinq/phoenix/db/ChannelsDatabase.sq @@ -0,0 +1,48 @@ +import kotlin.Boolean; + +PRAGMA foreign_keys = 1; + +-- channels table +-- note: boolean are stored as INTEGER, with 0=false +CREATE TABLE IF NOT EXISTS local_channels ( + channel_id BLOB NOT NULL PRIMARY KEY, + data BLOB NOT NULL, + is_closed INTEGER AS Boolean DEFAULT 0 NOT NULL +); + +-- htlcs info table +CREATE TABLE IF NOT EXISTS htlc_infos ( + channel_id BLOB NOT NULL, + commitment_number INTEGER NOT NULL, + payment_hash BLOB NOT NULL, + cltv_expiry INTEGER NOT NULL, + FOREIGN KEY(channel_id) REFERENCES local_channels(channel_id) +); + +CREATE INDEX IF NOT EXISTS htlc_infos_idx ON htlc_infos(channel_id, commitment_number); + +-- channels queries +getChannel: +SELECT * FROM local_channels WHERE channel_id=?; + +updateChannel: +UPDATE local_channels SET data=? WHERE channel_id=?; + +insertChannel: +INSERT INTO local_channels VALUES (?, ?, 0); + +closeLocalChannel: +UPDATE local_channels SET is_closed=1 WHERE channel_id=?; + +listLocalChannels: +SELECT data FROM local_channels WHERE is_closed=0; + +-- htlcs info queries +insertHtlcInfo: +INSERT INTO htlc_infos VALUES (?, ?, ?, ?); + +listHtlcInfos: +SELECT payment_hash, cltv_expiry FROM htlc_infos WHERE channel_id=? AND commitment_number=?; + +deleteHtlcInfo: +DELETE FROM htlc_infos WHERE channel_id=?; \ No newline at end of file diff --git a/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/ChannelCloseOutgoingPayments.sq b/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/ChannelCloseOutgoingPayments.sq new file mode 100644 index 0000000..b495f3e --- /dev/null +++ b/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/ChannelCloseOutgoingPayments.sq @@ -0,0 +1,38 @@ +import fr.acinq.lightning.bin.db.payments.OutgoingPartClosingInfoTypeVersion; + +-- Store in a flat row outgoing payments standing for channel-closing. +-- There are no complex json columns like in the outgoing_payments table. +-- This table replaces the legacy outgoing_payment_closing_tx_parts table. +CREATE TABLE IF NOT EXISTS channel_close_outgoing_payments ( + id TEXT NOT NULL PRIMARY KEY, + recipient_amount_sat INTEGER NOT NULL, + address TEXT NOT NULL, + is_default_address INTEGER NOT NULL, + mining_fees_sat INTEGER NOT NULL, + tx_id BLOB NOT NULL, + created_at INTEGER NOT NULL, + confirmed_at INTEGER DEFAULT NULL, + locked_at INTEGER DEFAULT NULL, + channel_id BLOB NOT NULL, + closing_info_type TEXT AS OutgoingPartClosingInfoTypeVersion NOT NULL, + closing_info_blob BLOB NOT NULL +); + +insertChannelCloseOutgoing: +INSERT INTO channel_close_outgoing_payments ( + id, recipient_amount_sat, address, is_default_address, mining_fees_sat, tx_id, created_at, confirmed_at, locked_at, channel_id, closing_info_type, closing_info_blob +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + +setConfirmed: +UPDATE channel_close_outgoing_payments SET confirmed_at=? WHERE id=?; + +setLocked: +UPDATE channel_close_outgoing_payments SET locked_at=? WHERE id=?; + +getChannelCloseOutgoing: +SELECT id, recipient_amount_sat, address, is_default_address, mining_fees_sat, tx_id, created_at, confirmed_at, locked_at, channel_id, closing_info_type, closing_info_blob +FROM channel_close_outgoing_payments +WHERE id=?; + +deleteChannelCloseOutgoing: +DELETE FROM channel_close_outgoing_payments WHERE id=?; diff --git a/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/InboundLiquidityOutgoing.sq b/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/InboundLiquidityOutgoing.sq new file mode 100644 index 0000000..89b28e0 --- /dev/null +++ b/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/InboundLiquidityOutgoing.sq @@ -0,0 +1,34 @@ +import fr.acinq.lightning.bin.db.payments.InboundLiquidityLeaseTypeVersion; + +-- Stores in a flat row payments standing for an inbound liquidity request (which are done through a splice). +-- The lease data are stored in a complex column, as a json-encoded blob. See InboundLiquidityLeaseType file. +CREATE TABLE IF NOT EXISTS inbound_liquidity_outgoing_payments ( + id TEXT NOT NULL PRIMARY KEY, + mining_fees_sat INTEGER NOT NULL, + channel_id BLOB NOT NULL, + tx_id BLOB NOT NULL, + lease_type TEXT AS InboundLiquidityLeaseTypeVersion NOT NULL, + lease_blob BLOB NOT NULL, + created_at INTEGER NOT NULL, + confirmed_at INTEGER DEFAULT NULL, + locked_at INTEGER DEFAULT NULL +); + +insert: +INSERT INTO inbound_liquidity_outgoing_payments ( + id, mining_fees_sat, channel_id, tx_id, lease_type, lease_blob, created_at, confirmed_at, locked_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + +setConfirmed: +UPDATE inbound_liquidity_outgoing_payments SET confirmed_at=? WHERE id=?; + +setLocked: +UPDATE inbound_liquidity_outgoing_payments SET locked_at=? WHERE id=?; + +get: +SELECT id, mining_fees_sat, channel_id, tx_id, lease_type, lease_blob, created_at, confirmed_at, locked_at +FROM inbound_liquidity_outgoing_payments +WHERE id=?; + +delete: +DELETE FROM inbound_liquidity_outgoing_payments WHERE id=?; diff --git a/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/IncomingPayments.sq b/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/IncomingPayments.sq new file mode 100644 index 0000000..c069c70 --- /dev/null +++ b/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/IncomingPayments.sq @@ -0,0 +1,98 @@ +import fr.acinq.lightning.bin.db.payments.IncomingOriginTypeVersion; +import fr.acinq.lightning.bin.db.payments.IncomingReceivedWithTypeVersion; + +-- incoming payments +CREATE TABLE IF NOT EXISTS incoming_payments ( + payment_hash BLOB NOT NULL PRIMARY KEY, + preimage BLOB NOT NULL, + created_at INTEGER NOT NULL, + -- origin + origin_type TEXT AS IncomingOriginTypeVersion NOT NULL, + origin_blob BLOB NOT NULL, + -- this field is legacy, the amount received is the sum of the received-with parts + received_amount_msat INTEGER DEFAULT NULL, + -- timestamp when the payment has been received + received_at INTEGER DEFAULT NULL, + -- received-with parts + received_with_type TEXT AS IncomingReceivedWithTypeVersion DEFAULT NULL, + received_with_blob BLOB DEFAULT NULL +); + +-- Create indexes to optimize the queries in AggregatedQueries. +-- Tip: Use "explain query plan" to ensure they're actually being used. +CREATE INDEX IF NOT EXISTS incoming_payments_filter_idx + ON incoming_payments(received_at) + WHERE received_at IS NOT NULL; + +-- queries + +insert: +INSERT INTO incoming_payments ( + payment_hash, + preimage, + created_at, + origin_type, + origin_blob) +VALUES (?, ?, ?, ?, ?); + +updateReceived: +UPDATE incoming_payments +SET received_at=?, + received_with_type=?, + received_with_blob=? +WHERE payment_hash = ?; + +insertAndReceive: +INSERT INTO incoming_payments ( + payment_hash, + preimage, + created_at, + origin_type, origin_blob, + received_at, + received_with_type, + received_with_blob) +VALUES (?, ?, ?, ?, ?, ?, ?, ?); + +get: +SELECT payment_hash, preimage, created_at, origin_type, origin_blob, received_amount_msat, received_at, received_with_type, received_with_blob +FROM incoming_payments +WHERE payment_hash=?; + +getOldestReceivedDate: +SELECT received_at +FROM incoming_payments AS r +WHERE received_at IS NOT NULL +ORDER BY r.received_at ASC +LIMIT 1; + +listAllWithin: +SELECT payment_hash, preimage, created_at, origin_type, origin_blob, received_amount_msat, received_at, received_with_type, received_with_blob +FROM incoming_payments +WHERE created_at BETWEEN :from AND :to +ORDER BY + coalesce(received_at, created_at) DESC, + payment_hash DESC; + +listAllNotConfirmed: +SELECT incoming_payments.payment_hash, preimage, created_at, origin_type, origin_blob, received_amount_msat, received_at, received_with_type, received_with_blob +FROM incoming_payments +LEFT OUTER JOIN link_tx_to_payments + ON link_tx_to_payments.type = 1 + AND link_tx_to_payments.confirmed_at IS NULL + AND link_tx_to_payments.id = incoming_payments.payment_hash +WHERE received_at IS NOT NULL +; + +scanCompleted: +SELECT payment_hash, + received_at +FROM incoming_payments +WHERE received_at IS NOT NULL; + +delete: +DELETE FROM incoming_payments +WHERE payment_hash = ?; + +-- use this in a `transaction` block to know how many rows were changed after an UPDATE +changes: +SELECT changes(); diff --git a/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/LinkTxToPayment.sq b/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/LinkTxToPayment.sq new file mode 100644 index 0000000..ad91f6a --- /dev/null +++ b/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/LinkTxToPayment.sq @@ -0,0 +1,29 @@ +-- This table links an on-chain transaction to one/many incoming or outgoing payment +-- * tx_id => hex identifier of an on-chain transaction +-- * type => tracks the type of a payment. The value is an int as defined in the DbType enum +-- * id => the identifier of the payment, can be a payment hash (incoming) or a UUID (outgoing) +CREATE TABLE IF NOT EXISTS link_tx_to_payments ( + tx_id BLOB NOT NULL, + type INTEGER NOT NULL, + id TEXT NOT NULL, + confirmed_at INTEGER DEFAULT NULL, + locked_at INTEGER DEFAULT NULL, + PRIMARY KEY (tx_id, type, id) +); + +CREATE INDEX IF NOT EXISTS link_tx_to_payments_txid ON link_tx_to_payments(tx_id); + +listUnconfirmed: +SELECT DISTINCT(tx_id) FROM link_tx_to_payments WHERE confirmed_at IS NULL; + +getPaymentIdForTx: +SELECT tx_id, type, id FROM link_tx_to_payments WHERE tx_id=?; + +linkTxToPayment: +INSERT INTO link_tx_to_payments(tx_id, type, id) VALUES (?, ?, ?); + +setConfirmed: +UPDATE link_tx_to_payments SET confirmed_at=? WHERE tx_id=?; + +setLocked: +UPDATE link_tx_to_payments SET locked_at=? WHERE tx_id=?; diff --git a/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/OutgoingPayments.sq b/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/OutgoingPayments.sq new file mode 100644 index 0000000..5221061 --- /dev/null +++ b/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/OutgoingPayments.sq @@ -0,0 +1,228 @@ +import fr.acinq.lightning.db.HopDesc; +import fr.acinq.lightning.bin.db.payments.OutgoingDetailsTypeVersion; +import fr.acinq.lightning.bin.db.payments.OutgoingPartClosingInfoTypeVersion; +import fr.acinq.lightning.bin.db.payments.OutgoingPartStatusTypeVersion; +import fr.acinq.lightning.bin.db.payments.OutgoingStatusTypeVersion; +import kotlin.collections.List; + +PRAGMA foreign_keys = 1; + +-- outgoing payments +-- Stores an outgoing payment in a flat row. Some columns can be null. +CREATE TABLE IF NOT EXISTS outgoing_payments ( + id TEXT NOT NULL PRIMARY KEY, + recipient_amount_msat INTEGER NOT NULL, + recipient_node_id TEXT NOT NULL, + payment_hash BLOB NOT NULL, + created_at INTEGER NOT NULL, + -- details + details_type TEXT AS OutgoingDetailsTypeVersion NOT NULL, + details_blob BLOB NOT NULL, + -- status + completed_at INTEGER DEFAULT NULL, + status_type TEXT AS OutgoingStatusTypeVersion DEFAULT NULL, + status_blob BLOB DEFAULT NULL +); + +-- Create indexes to optimize the queries in AggregatedQueries. +-- Tip: Use "explain query plan" to ensure they're actually being used. +CREATE INDEX IF NOT EXISTS outgoing_payments_filter_idx + ON outgoing_payments(completed_at); + +-- Stores the lightning parts that make up a lightning payment +CREATE TABLE IF NOT EXISTS outgoing_payment_parts ( + part_id TEXT NOT NULL PRIMARY KEY, + part_parent_id TEXT NOT NULL, + part_amount_msat INTEGER NOT NULL, + part_route TEXT AS List NOT NULL, + part_created_at INTEGER NOT NULL, + -- status + part_completed_at INTEGER DEFAULT NULL, + part_status_type TEXT AS OutgoingPartStatusTypeVersion DEFAULT NULL, + part_status_blob BLOB DEFAULT NULL, + + FOREIGN KEY(part_parent_id) REFERENCES outgoing_payments(id) +); + +-- !! This table is legacy, and will only contain old payments. See ChannelCloseOutgoingPayment.sq for the new table. +-- Stores the transactions that close a channel +CREATE TABLE IF NOT EXISTS outgoing_payment_closing_tx_parts ( + part_id TEXT NOT NULL PRIMARY KEY, + part_parent_id TEXT NOT NULL, + part_tx_id BLOB NOT NULL, + part_amount_sat INTEGER NOT NULL, + part_closing_info_type TEXT AS OutgoingPartClosingInfoTypeVersion NOT NULL, + part_closing_info_blob BLOB NOT NULL, + part_created_at INTEGER NOT NULL, + + FOREIGN KEY(part_parent_id) REFERENCES outgoing_payments(id) +); + +-- A FOREIGN KEY does NOT create an implicit index. +-- One would expect it to, but it doesn't. +-- As per the docs (https://sqlite.org/foreignkeys.html): +-- > Indices are not required for child key columns but they are almost always beneficial. +-- > [...] So, in most real systems, an index should be created on the child key columns +-- > of each foreign key constraint. +CREATE INDEX IF NOT EXISTS parent_id_idx ON outgoing_payment_parts(part_parent_id); +CREATE INDEX IF NOT EXISTS parent_id_idx ON outgoing_payment_closing_tx_parts(part_parent_id); + +-- queries for outgoing payments + +hasPayment: +SELECT COUNT(*) FROM outgoing_payments +WHERE id = ?; + +insertPayment: +INSERT INTO outgoing_payments ( + id, + recipient_amount_msat, + recipient_node_id, + payment_hash, + created_at, + details_type, + details_blob) +VALUES (?, ?, ?, ?, ?, ?, ?); + +updatePayment: +UPDATE outgoing_payments SET completed_at=?, status_type=?, status_blob=? WHERE id=?; + +scanCompleted: +SELECT id, completed_at +FROM outgoing_payments +WHERE completed_at IS NOT NULL; + +deletePayment: +DELETE FROM outgoing_payments WHERE id = ?; + +-- queries for lightning parts + +countLightningPart: +SELECT COUNT(*) FROM outgoing_payment_parts WHERE part_id = ?; + +insertLightningPart: +INSERT INTO outgoing_payment_parts ( + part_id, + part_parent_id, + part_amount_msat, + part_route, + part_created_at) +VALUES (?, ?, ?, ?, ?); + +updateLightningPart: +UPDATE outgoing_payment_parts +SET part_status_type=?, + part_status_blob=?, + part_completed_at=? +WHERE part_id=?; + +getLightningPart: +SELECT * FROM outgoing_payment_parts WHERE part_id=?; + +deleteLightningPartsForParentId: +DELETE FROM outgoing_payment_parts WHERE part_parent_id = ?; + +-- queries for closing tx parts + +countClosingTxPart: +SELECT COUNT(*) FROM outgoing_payment_closing_tx_parts WHERE part_id = ?; + +insertClosingTxPart: +INSERT INTO outgoing_payment_closing_tx_parts ( + part_id, + part_parent_id, + part_tx_id, + part_amount_sat, + part_closing_info_type, + part_closing_info_blob, + part_created_at +) VALUES (:id, :parent_id, :tx_id, :amount_msat, :closing_info_type, :closing_info_blob, :created_at); + +-- queries mixing outgoing payments and parts + +getPaymentWithoutParts: +SELECT id, + recipient_amount_msat, + recipient_node_id, + payment_hash, + details_type, + details_blob, + created_at, + completed_at, + status_type, + status_blob +FROM outgoing_payments +WHERE id=?; + +getOldestCompletedDate: +SELECT completed_at +FROM outgoing_payments AS o +WHERE completed_at IS NOT NULL +ORDER BY o.completed_at ASC +LIMIT 1; + +getPayment: +SELECT parent.id, + parent.recipient_amount_msat, + parent.recipient_node_id, + parent.payment_hash, + parent.details_type, + parent.details_blob, + parent.created_at, + parent.completed_at, + parent.status_type, + parent.status_blob, + -- lightning parts + lightning_parts.part_id AS lightning_part_id, + lightning_parts.part_amount_msat AS lightning_part_amount_msat, + lightning_parts.part_route AS lightning_part_route, + lightning_parts.part_created_at AS lightning_part_created_at, + lightning_parts.part_completed_at AS lightning_part_completed_at, + lightning_parts.part_status_type AS lightning_part_status_type, + lightning_parts.part_status_blob AS lightning_part_status_blob, + -- closing tx parts + closing_parts.part_id AS closingtx_part_id, + closing_parts.part_tx_id AS closingtx_tx_id, + closing_parts.part_amount_sat AS closingtx_amount_sat, + closing_parts.part_closing_info_type AS closingtx_info_type, + closing_parts.part_closing_info_blob AS closingtx_info_blob, + closing_parts.part_created_at AS closingtx_created_at +FROM outgoing_payments AS parent +LEFT OUTER JOIN outgoing_payment_parts AS lightning_parts ON lightning_parts.part_parent_id = parent.id +LEFT OUTER JOIN outgoing_payment_closing_tx_parts AS closing_parts ON closing_parts.part_parent_id = parent.id +WHERE parent.id=?; + +listPaymentsForPaymentHash: +SELECT parent.id, + parent.recipient_amount_msat, + parent.recipient_node_id, + parent.payment_hash, + parent.details_type, + parent.details_blob, + parent.created_at, + parent.completed_at, + parent.status_type, + parent.status_blob, + -- lightning parts + lightning_parts.part_id AS lightning_part_id, + lightning_parts.part_amount_msat AS lightning_part_amount_msat, + lightning_parts.part_route AS lightning_part_route, + lightning_parts.part_created_at AS lightning_part_created_at, + lightning_parts.part_completed_at AS lightning_part_completed_at, + lightning_parts.part_status_type AS lightning_part_status_type, + lightning_parts.part_status_blob AS lightning_part_status_blob, + -- closing tx parts + closing_parts.part_id AS closingtx_part_id, + closing_parts.part_tx_id AS closingtx_tx_id, + closing_parts.part_amount_sat AS closingtx_amount_sat, + closing_parts.part_closing_info_type AS closingtx_info_type, + closing_parts.part_closing_info_blob AS closingtx_info_blob, + closing_parts.part_created_at AS closingtx_created_at +FROM outgoing_payments AS parent +LEFT OUTER JOIN outgoing_payment_parts AS lightning_parts ON lightning_parts.part_parent_id = parent.id +LEFT OUTER JOIN outgoing_payment_closing_tx_parts AS closing_parts ON closing_parts.part_parent_id = parent.id +WHERE payment_hash=?; + +-- use this in a `transaction` block to know how many rows were changed after an UPDATE +changes: +SELECT changes(); diff --git a/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/SpliceCpfpOutgoingPayments.sq b/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/SpliceCpfpOutgoingPayments.sq new file mode 100644 index 0000000..77de7da --- /dev/null +++ b/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/SpliceCpfpOutgoingPayments.sq @@ -0,0 +1,32 @@ +import fr.acinq.lightning.bin.db.payments.OutgoingPartClosingInfoTypeVersion; + +-- Store in a flat row the outgoing payments standing for a CPFP (which are done throuh a splice). +-- There are no complex json columns like in the outgoing_payments table. +CREATE TABLE IF NOT EXISTS splice_cpfp_outgoing_payments ( + id TEXT NOT NULL PRIMARY KEY, + mining_fees_sat INTEGER NOT NULL, + channel_id BLOB NOT NULL, + tx_id BLOB NOT NULL, + created_at INTEGER NOT NULL, + confirmed_at INTEGER DEFAULT NULL, + locked_at INTEGER DEFAULT NULL +); + +insertCpfp: +INSERT INTO splice_cpfp_outgoing_payments ( + id, mining_fees_sat, channel_id, tx_id, created_at, confirmed_at, locked_at +) VALUES (?, ?, ?, ?, ?, ?, ?); + +setConfirmed: +UPDATE splice_cpfp_outgoing_payments SET confirmed_at=? WHERE id=?; + +setLocked: +UPDATE splice_cpfp_outgoing_payments SET locked_at=? WHERE id=?; + +getCpfp: +SELECT id, mining_fees_sat, channel_id, tx_id, created_at, confirmed_at, locked_at +FROM splice_cpfp_outgoing_payments +WHERE id=?; + +deleteCpfp: +DELETE FROM splice_cpfp_outgoing_payments WHERE id=?; diff --git a/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/SpliceOutgoingPayments.sq b/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/SpliceOutgoingPayments.sq new file mode 100644 index 0000000..52880e1 --- /dev/null +++ b/src/commonMain/sqldelight/paymentsdb/fr/acinq/phoenix/db/SpliceOutgoingPayments.sq @@ -0,0 +1,32 @@ +-- store a splice-out payment in a flat row +-- there are no complex json columns like in the outgoing_payments table +CREATE TABLE IF NOT EXISTS splice_outgoing_payments ( + id TEXT NOT NULL PRIMARY KEY, + recipient_amount_sat INTEGER NOT NULL, + address TEXT NOT NULL, + mining_fees_sat INTEGER NOT NULL, + tx_id BLOB NOT NULL, + channel_id BLOB NOT NULL, + created_at INTEGER NOT NULL, + confirmed_at INTEGER DEFAULT NULL, + locked_at INTEGER DEFAULT NULL +); + +insertSpliceOutgoing: +INSERT INTO splice_outgoing_payments ( + id, recipient_amount_sat, address, mining_fees_sat, tx_id, channel_id, created_at, confirmed_at, locked_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + +setConfirmed: +UPDATE splice_outgoing_payments SET confirmed_at=? WHERE id=?; + +setLocked: +UPDATE splice_outgoing_payments SET locked_at=? WHERE id=?; + +getSpliceOutgoing: +SELECT id, recipient_amount_sat, address, mining_fees_sat, tx_id, channel_id, created_at, confirmed_at, locked_at +FROM splice_outgoing_payments +WHERE id=?; + +deleteSpliceOutgoing: +DELETE FROM splice_outgoing_payments WHERE id=?; diff --git a/src/jvmMain/kotlin/fr/acinq/lightning/bin/Actuals.kt b/src/jvmMain/kotlin/fr/acinq/lightning/bin/Actuals.kt new file mode 100644 index 0000000..0578e6b --- /dev/null +++ b/src/jvmMain/kotlin/fr/acinq/lightning/bin/Actuals.kt @@ -0,0 +1,24 @@ +package fr.acinq.lightning.bin + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import fr.acinq.phoenix.db.ChannelsDatabase +import fr.acinq.phoenix.db.PaymentsDatabase +import okio.Path +import okio.Path.Companion.toPath + +actual val homeDirectory: Path = System.getProperty("user.home").toPath() + +actual fun createAppDbDriver(dir: Path): SqlDriver { + val path = dir / "phoenix.db" + val driver = JdbcSqliteDriver("jdbc:sqlite:$path") + ChannelsDatabase.Schema.create(driver) + return driver +} + +actual fun createPaymentsDbDriver(dir: Path): SqlDriver { + val path = dir / "payments.db" + val driver = JdbcSqliteDriver("jdbc:sqlite:$path") + PaymentsDatabase.Schema.create(driver) + return driver +} diff --git a/src/nativeMain/kotlin/fr/acinq/lightning/bin/Actuals.kt b/src/nativeMain/kotlin/fr/acinq/lightning/bin/Actuals.kt new file mode 100644 index 0000000..ea24629 --- /dev/null +++ b/src/nativeMain/kotlin/fr/acinq/lightning/bin/Actuals.kt @@ -0,0 +1,27 @@ +package fr.acinq.lightning.bin + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.native.NativeSqliteDriver +import fr.acinq.phoenix.db.ChannelsDatabase +import fr.acinq.phoenix.db.PaymentsDatabase +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.toKString +import okio.Path +import okio.Path.Companion.toPath +import platform.posix.getenv +import platform.posix.setenv + +@OptIn(ExperimentalForeignApi::class) +actual val homeDirectory: Path = setenv("KTOR_LOG_LEVEL", "WARN", 1).let { getenv("HOME")?.toKString()!!.toPath() } + +actual fun createAppDbDriver(dir: Path): SqlDriver { + return NativeSqliteDriver(ChannelsDatabase.Schema, "phoenix.db", + onConfiguration = { it.copy(extendedConfig = it.extendedConfig.copy(basePath = dir.toString())) } + ) +} + +actual fun createPaymentsDbDriver(dir: Path): SqlDriver { + return NativeSqliteDriver(PaymentsDatabase.Schema, "payments.db", + onConfiguration = { it.copy(extendedConfig = it.extendedConfig.copy(basePath = dir.toString())) } + ) +} \ No newline at end of file