Initial commit

This commit is contained in:
Pierre-Marie Padiou 2024-03-08 18:44:24 +01:00 committed by pm47
commit d791179125
No known key found for this signature in database
GPG Key ID: E434ED292E85643A
55 changed files with 4985 additions and 0 deletions

46
.gitignore vendored Normal file
View File

@ -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

152
CONTRIBUTING.md Normal file
View File

@ -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

201
LICENSE Normal file
View File

@ -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.

1
README.md Normal file
View File

@ -0,0 +1 @@
# phoenixd

119
build.gradle.kts Normal file
View File

@ -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<JavaExec> {
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")
}
}
}

9
gradle.properties Normal file
View File

@ -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

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -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

249
gradlew vendored Executable file
View File

@ -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" "$@"

92
gradlew.bat vendored Normal file
View File

@ -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

9
settings.gradle.kts Normal file
View File

@ -0,0 +1,9 @@
rootProject.name = "phoenixd"
pluginManagement {
repositories {
gradlePluginPortal()
maven("https://dl.bintray.com/kotlin/kotlin-eap")
}
}

View File

@ -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<ApiEvent>, 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<Throwable> { 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<ChannelStateWithCommitments>()
.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")
}

View File

@ -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

View File

@ -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<ByteVector32, IncomingPayment>()
private val outgoing = mutableMapOf<UUID, LightningOutgoingPayment>()
private val outgoingParts = mutableMapOf<UUID, Pair<UUID, LightningOutgoingPayment.Part>>()
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<IncomingPayment.ReceivedWith>, 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<IncomingPayment> =
incoming.values
.asSequence()
.sortedByDescending { it.createdAt }
.drop(skip)
.take(count)
.toList()
override suspend fun listExpiredPayments(fromCreatedAt: Long, toCreatedAt: Long): List<IncomingPayment> =
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<LightningOutgoingPayment.Part>) {
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<ChannelException, FailureMessage>, 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<LightningOutgoingPayment> {
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)
}
}
}

View File

@ -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<String>) = 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<ApiType.ApiEvent> = MutableSharedFlow<ApiType.ApiEvent>().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<PaymentEvents.PaymentReceived>()
.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<LiquidityEvents.Decision.Rejected>()
.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() }
}
}

View File

@ -0,0 +1,19 @@
package fr.acinq.lightning.bin.conf
import okio.FileSystem
import okio.Path
fun readConfFile(confFile: Path): Map<String, String> = 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()
}

View File

@ -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
// )
// }
}
}

View File

@ -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<ByteVector, Boolean> {
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
}

View File

@ -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<PersistedChannelState> = 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<Pair<ByteVector32, CltvExpiry>> {
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()
}
}

View File

@ -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<LightningOutgoingPayment.Part>
) {
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<ChannelException, FailureMessage>,
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<LightningOutgoingPayment> = 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<IncomingPayment.ReceivedWith>,
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<IncomingPayment.ReceivedWith.OnChainIncomingPayment>().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<IncomingPayment> = withContext(Dispatchers.Default) {
inQueries.listExpiredPayments(fromCreatedAt, toCreatedAt)
}
override suspend fun removeIncomingPayment(
paymentHash: ByteVector32
): Boolean = withContext(Dispatchers.Default) {
inQueries.deleteIncomingPayment(paymentHash)
}
}

View File

@ -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)
}

View File

@ -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),
)
}
}
}

View File

@ -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 <T> 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 }
}

View File

@ -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<V0>(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)
}

View File

@ -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
)
}
}
}

View File

@ -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<Invoice.V0>(json).let { IncomingPayment.Origin.Invoice(Bolt11Invoice.read(it.paymentRequest).get()) }
IncomingOriginTypeVersion.SWAPIN_V0 -> format.decodeFromString<SwapIn.V0>(json).let { IncomingPayment.Origin.SwapIn(it.address) }
IncomingOriginTypeVersion.ONCHAIN_V0 -> format.decodeFromString<OnChain.V0>(json).let {
IncomingPayment.Origin.OnChain(TxId(it.txId), it.outpoints.toSet())
}
}
}
}
}
fun IncomingPayment.Origin.mapToDb(): Pair<IncomingOriginTypeVersion, ByteArray> = 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)
}

View File

@ -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<IncomingPayment.ReceivedWith>,
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<List<IncomingPayment>> {
return queries.listAllNotConfirmed(Companion::mapIncomingPayment).asFlow().mapToList(Dispatchers.IO)
}
fun listExpiredPayments(fromCreatedAt: Long, toCreatedAt: Long): List<IncomingPayment> {
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<ByteVector32, ByteVector32> {
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 ]")

View File

@ -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<IncomingPayment.ReceivedWith> = 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<IncomingPayment.ReceivedWith>.mapToDb(): Pair<IncomingReceivedWithTypeVersion, ByteArray>? = 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)
}

View File

@ -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<List<ByteArray>> {
return linkTxQueries.listUnconfirmed().asFlow().mapToList(Dispatchers.IO)
}
fun listWalletPaymentIdsForTx(txId: TxId): List<WalletPaymentId> {
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)
}
}

View File

@ -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<Normal.V0>(json).let { LightningOutgoingPayment.Details.Normal(Bolt11Invoice.read(it.paymentRequest).get()) }
OutgoingDetailsTypeVersion.KEYSEND_V0 -> format.decodeFromString<KeySend.V0>(json).let { LightningOutgoingPayment.Details.KeySend(it.preimage) }
OutgoingDetailsTypeVersion.SWAPOUT_V0 -> format.decodeFromString<SwapOut.V0>(json).let { LightningOutgoingPayment.Details.SwapOut(it.address, Bolt11Invoice.read(it.paymentRequest).get(), it.swapOutFee) }
}
}
}
}
fun LightningOutgoingPayment.Details.mapToDb(): Pair<OutgoingDetailsTypeVersion, ByteArray> = 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)
}

View File

@ -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<V0>(json).closingType
}
}
}
}
fun ChannelCloseOutgoingPayment.mapClosingTypeToDb() = OutgoingPartClosingInfoTypeVersion.CLOSING_INFO_V0 to
Json.encodeToString(OutgoingPartClosingInfoData.V0(this.closingType)).toByteArray(Charsets.UTF_8)

View File

@ -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<Succeeded.V0>(json).let {
LightningOutgoingPayment.Part.Status.Succeeded(it.preimage, completedAt)
}
OutgoingPartStatusTypeVersion.FAILED_V0 -> format.decodeFromString<Failed.V0>(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)

View File

@ -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<LightningOutgoingPayment.Part>) {
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<ChannelException, FailureMessage>,
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<LightningOutgoingPayment>()?.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<LightningOutgoingPayment>().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<LightningOutgoingPayment>().let {
groupByRawLightningOutgoing(it).firstOrNull()
}?.let {
filterUselessParts(it)
}
}
fun getOldestCompletedDate(): Long? {
return queries.getOldestCompletedDate().executeAsOneOrNull()
}
fun listLightningOutgoingPayments(paymentHash: ByteVector32): List<LightningOutgoingPayment> {
return queries.listPaymentsForPaymentHash(paymentHash.toByteArray(), Companion::mapLightningOutgoingPayment).executeAsList()
.filterIsInstance<LightningOutgoingPayment>()
.let { groupByRawLightningOutgoing(it) }
}
/** Group a list of outgoing payments by parent id and parts. */
private fun groupByRawLightningOutgoing(payments: List<LightningOutgoingPayment>) = 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<HopDesc>?,
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<HopDesc>,
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<List<HopDesc>, String> = object : ColumnAdapter<List<HopDesc>, String> {
override fun decode(databaseValue: String): List<HopDesc> = 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<HopDesc>): 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]")

View File

@ -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<SucceededOnChain.V0>(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<SucceededOffChain.V0>(json).let {
LightningOutgoingPayment.Status.Completed.Succeeded.OffChain(it.preimage, completedAt)
}
OutgoingStatusTypeVersion.FAILED_V0 -> format.decodeFromString<Failed.V0>(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<OutgoingStatusTypeVersion, ByteArray> = 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)
}

View File

@ -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
)
}
}
}

View File

@ -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
)
}
}
}

View File

@ -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<T>(
name: String,
private val toString: (T) -> String,
private val fromString: (String) -> T
) : KSerializer<T> {
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())
}
}

View File

@ -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<ByteVector32>(
name = "ByteVector32",
toString = ByteVector32::toHex,
fromString = ::ByteVector32
)
object ByteVector64Serializer : AbstractStringSerializer<ByteVector64>(
name = "ByteVector64",
toString = ByteVector64::toHex,
fromString = ::ByteVector64
)
object ByteVectorSerializer : AbstractStringSerializer<ByteVector>(
name = "ByteVector",
toString = ByteVector::toHex,
fromString = ::ByteVector
)

View File

@ -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<MilliSatoshi> {
// 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)
}
}

View File

@ -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<OutPoint>(
name = "Outpoint",
fromString = { serialized ->
serialized.split(":").let {
OutPoint(hash = TxHash(it[0]), index = it[1].toLong())
}
},
toString = { outpoint -> "${outpoint.hash}:${outpoint.index}" }
)

View File

@ -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<Satoshi> {
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())
}
}

View File

@ -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<UUID> {
@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)
}
}

View File

@ -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<Channel>
)
@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())
}
}

View File

@ -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<String> = 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()
}
}
}
}

View File

@ -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<String>) =
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<HttpConf>()
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<HttpConf>()
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<HttpConf>()
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<HttpConf>()
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<HttpConf>()
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<HttpConf>()
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<HttpConf>()
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))

View File

@ -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=?;

View File

@ -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=?;

View File

@ -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=?;

View File

@ -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();

View File

@ -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=?;

View File

@ -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<HopDesc> 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();

View File

@ -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=?;

View File

@ -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=?;

View File

@ -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
}

View File

@ -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())) }
)
}