Compare commits

..

18 Commits

Author SHA1 Message Date
Kukks
b4dea11bc6
Refactor the connection manager + sync 2024-07-30 15:57:17 +02:00
Kukks
f06cf66e81
Merge remote-tracking branch 'github.com/master' into jit-backups
# Conflicts:
#	submodules/btcpayserver
2024-07-29 13:38:12 +02:00
Kukks
e6ee0d8a2f
do not use syncer for now 2024-07-29 13:37:49 +02:00
Kukks
53fe9def9d
wip 2024-07-26 16:03:27 +02:00
Kukks
12ef00719e
restorer 2024-07-26 13:21:41 +02:00
Kukks
9b7a2ff7a2
moroe backup related code 2024-07-26 11:28:12 +02:00
Kukks
b4896685cb
Merge remote-tracking branch 'github.com/master' into jit-backups
# Conflicts:
#	BTCPayApp.UI/Pages/Settings/LightningPage.razor
2024-07-25 14:54:40 +02:00
Kukks
9c09a440e2
WIP 2024-07-25 14:50:25 +02:00
Kukks
ec215938df
wip 2024-07-23 16:28:35 +02:00
Kukks
a05d039a55
Merge remote-tracking branch 'github.com/master' into jit-backups
# Conflicts:
#	BTCPayApp.Core/Attempt2/BTCPayAppServerClient.cs
#	BTCPayApp.Core/Attempt2/BTCPayConnectionManager.cs
#	BTCPayApp.UI/StateMiddleware.cs
#	submodules/btcpayserver
2024-07-22 12:03:48 +02:00
Kukks
f7fe33a730
separate file 2024-07-22 11:54:06 +02:00
Kukks
e02ca2ba78
WIP (submodule not updated) 2024-07-10 22:33:59 +02:00
Kukks
ee3a83c235
wip triggers 2024-07-10 11:45:14 +02:00
Kukks
4526824fb4
wip 2024-07-10 11:45:13 +02:00
Kukks
9953264836
Refactor connection to isolate json frameworks and start backup 2024-07-10 11:45:13 +02:00
Kukks
c1788faefe
theoretically functional 2024-07-10 11:44:27 +02:00
Kukks
86c699d823
crying my way to success 2024-07-10 11:44:26 +02:00
Kukks
a75e6b9929
jit wip
subm
2024-07-10 11:44:26 +02:00
434 changed files with 9666 additions and 26844 deletions

View File

@ -1,310 +0,0 @@
name: 'Test & Build the app'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
- '**/*.gitignore'
- '**/*.gitattributes'
pull_request_target:
branches:
- master
env:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
# https://blog.taranissoftware.com/build-net-maui-apps-with-github-actions
jobs:
test-desktop:
runs-on: ubuntu-latest
env:
CI: true
BTCPAY_SERVER_URL: http://localhost:14142
steps:
# Setup code, .NET and Android
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Build
run: dotnet build --configuration Release BTCPayApp.Server
# Setup infrastructure
- name: Start containers
run: |
docker compose -f "submodules/btcpayserver/BTCPayServer.Tests/docker-compose.yml" build
docker compose -f "submodules/btcpayserver/BTCPayServer.Tests/docker-compose.yml" up -d dev
- name: Start BTCPay
run: |
./setup.sh
cd submodules/btcpayserver
# Start non-HTTPS to avoid certificate errors
nohup dotnet run -c Debug --project BTCPayServer --launch-profile Bitcoin &
while ! curl -s -k http://localhost:14142/api/v1/health > /dev/null; do
echo "Waiting for BTCPay Server to start..."
sleep 10
done
# Run tests
- name: Run tests
run: |
dotnet test -c Release -v n --logger "console;verbosity=normal" BTCPayApp.Tests
build-android:
runs-on: windows-latest
steps:
# Setup code, .NET and Android
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Install workloads
run: dotnet workload install maui
- name: Clean before build
run: |
dotnet clean BTCPayApp.Maui/BTCPayApp.Maui.csproj
- name: Build
# TODO: Use proper keystore once we switch to real releases
# https://learn.microsoft.com/en-us/dotnet/maui/android/deployment/publish-cli?view=net-maui-8.0#code-try-4
run: |
dotnet publish BTCPayApp.Maui/BTCPayApp.Maui.csproj -f net8.0-android -c Release -o publish/android
env:
ANDROID_SIGNING_PASSWORD: ${{ secrets.ANDROID_SIGNING_PASSWORD }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: org.btcpayserver.BTCPayApp-Signed.apk
path: publish/android/org.btcpayserver.BTCPayApp-Signed.apk
- name: Create pre-release
if: success() && github.ref == 'refs/heads/master'
uses: marvinpinto/action-automatic-releases@latest
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
automatic_release_tag: "latest"
prerelease: true
title: "Development Build"
files: |
publish/android/org.btcpayserver.BTCPayApp-Signed.apk
# build-desktop-win:
# runs-on: windows-latest
# steps:
# # Setup code, .NET and Android
# - uses: actions/checkout@v4
# with:
# submodules: recursive
# - name: Setup .NET
# uses: actions/setup-dotnet@v4
# with:
# dotnet-version: 8.0.x
# - name: Build win x64
# run: dotnet publish BTCPayApp.Photino/BTCPayApp.Photino.csproj -r win-x64 -c Debug -o publish/win-x64
# - name: Build win x86
# run: dotnet publish BTCPayApp.Photino/BTCPayApp.Photino.csproj -r win-x86 -c Debug -o publish/win-x86
# - name: Build win-arm64
# run: dotnet publish BTCPayApp.Photino/BTCPayApp.Photino.csproj -r win-arm64 -c Debug -o publish/win-arm64
# - name: Upload artifact win
# uses: actions/upload-artifact@v4
# with:
# name: windows build
# path: |
# publish/win-x64
# publish/win-x86
# publish/win-arm64
# build-desktop-linux:
# runs-on: ubuntu-latest
# steps:
# # Setup code, .NET and Android
# - uses: actions/checkout@v4
# with:
# submodules: recursive
# - name: Setup .NET
# uses: actions/setup-dotnet@v4
# with:
# dotnet-version: 8.0.x
# - name: Build linux x64
# run: dotnet publish BTCPayApp.Photino/BTCPayApp.Photino.csproj -r linux-x64 -c Debug -o publish/linux-x64
# - name: Build linux arm64
# run: dotnet publish BTCPayApp.Photino/BTCPayApp.Photino.csproj -r linux-arm64 -c Debug -o publish/linux-arm64
# - name: Upload artifact linux
# uses: actions/upload-artifact@v4
# with:
# name: linux build
# path: |
# publish/linux-x64
# publish/linux-arm64
# build-desktop-mac:
# runs-on: macos-latest
# steps:
# # Checkout the code
# - uses: actions/checkout@v4
# with:
# submodules: recursive
# # Import code-signing certificates
# - name: Import Code-Signing Certificates
# uses: apple-actions/import-codesign-certs@v3
# with:
# p12-file-base64: ${{ secrets.APPLE_CERT_P12_BASE64 }}
# p12-password: ${{ secrets.APPLE_CERT_P12_PASS }}
# # Verify imported certificates and extract Developer ID
# - name: Find Code Signing Certificate
# id: find-cert
# run: |
# CERT_NAME=$(security find-identity -v -p codesigning | grep "Apple Distribution" | awk -F '"Apple Distribution: ' '{print $2}' | awk -F '"' '{print $1}')
# if [ -z "$CERT_NAME" ]; then
# echo "No valid Apple Distribution certificate found!"
# exit 1
# fi
# echo "Certificate Name: Apple Distribution: $CERT_NAME"
# echo "CERT_NAME=Apple Distribution: $CERT_NAME" >> $GITHUB_ENV
# # Setup .NET
# - name: Setup .NET
# uses: actions/setup-dotnet@v4
# with:
# dotnet-version: 8.0.x
# # Build the app for macOS architectures
# - name: Build mac x64
# run: dotnet publish BTCPayApp.Photino/BTCPayApp.Photino.csproj -c Release --self-contained -r osx-x64 -o publish/osx-x64
# - name: Build mac arm64
# run: dotnet publish BTCPayApp.Photino/BTCPayApp.Photino.csproj -c Release --self-contained -r osx-arm64 -o publish/osx-arm64
# # Create the .app bundle
# - name: Create .app bundle for x64
# run: |
# mkdir -p dist/osx-x64/BTCPayApp.app/Contents/MacOS
# mkdir -p dist/osx-x64/BTCPayApp.app/Contents/Resources
# ls -lA publish/osx-x64/
# cp -R publish/osx-x64/BTCPayApp.Photino dist/osx-x64/BTCPayApp.app/Contents/MacOS/BTCPayApp
# #cp -R publish/osx-x64/* dist/osx-x64/BTCPayApp.app/Contents/MacOS/
# #mv dist/osx-x64/BTCPayApp.app/Contents/MacOS/BTCPayApp.Photino dist/osx-x64/BTCPayApp.app/Contents/MacOS/BTCPayApp
# echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>
# <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
# <plist version=\"1.0\">
# <dict>
# <key>CFBundleExecutable</key>
# <string>BTCPayApp</string>
# <key>CFBundleIdentifier</key>
# <string>org.btcpayserver.app</string>
# <key>CFBundleName</key>
# <string>BTCPay App</string>
# <key>CFBundleVersion</key>
# <string>1.0</string>
# <key>CFBundlePackageType</key>
# <string>APPL</string>
# </dict>
# </plist>" > dist/osx-x64/BTCPayApp.app/Contents/Info.plist
# cat dist/osx-x64/BTCPayApp.app/Contents/Info.plist
# ls -lA dist/osx-x64/BTCPayApp.app/Contents/**
# - name: Create .app bundle for arm64
# run: |
# mkdir -p dist/osx-arm64/BTCPayApp.app/Contents/MacOS
# mkdir -p dist/osx-arm64/BTCPayApp.app/Contents/Resources
# ls -lA publish/osx-arm64/
# cp -R publish/osx-arm64/BTCPayApp.Photino dist/osx-arm64/BTCPayApp.app/Contents/MacOS/BTCPayApp
# #cp -R publish/osx-arm64/* dist/osx-arm64/BTCPayApp.app/Contents/MacOS/
# #mv dist/osx-arm64/BTCPayApp.app/Contents/MacOS/BTCPayApp.Photino dist/osx-arm64/BTCPayApp.app/Contents/MacOS/BTCPayApp
# echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>
# <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
# <plist version=\"1.0\">
# <dict>
# <key>CFBundleExecutable</key>
# <string>BTCPayApp</string>
# <key>CFBundleIdentifier</key>
# <string>org.btcpayserver.app</string>
# <key>CFBundleName</key>
# <string>BTCPay App</string>
# <key>CFBundleVersion</key>
# <string>1.0</string>
# <key>CFBundlePackageType</key>
# <string>APPL</string>
# </dict>
# </plist>" > dist/osx-arm64/BTCPayApp.app/Contents/Info.plist
# cat dist/osx-arm64/BTCPayApp.app/Contents/Info.plist
# ls -lA dist/osx-arm64/BTCPayApp.app/Contents/**
# # Sign the .app bundles using the dynamic certificate name
# - name: Sign x64 app bundle
# run: |
# codesign --sign "$CERT_NAME" --deep --force --options runtime dist/osx-x64/BTCPayApp.app
# codesign --verify --deep --strict dist/osx-x64/BTCPayApp.app
# - name: Sign arm64 app bundle
# run: |
# codesign --sign "$CERT_NAME" --deep --force --options runtime dist/osx-arm64/BTCPayApp.app
# codesign --verify --deep --strict dist/osx-arm64/BTCPayApp.app
# # Verify app bundle signing
# - name: Verify x64 app bundle signing
# run: spctl --assess --type execute dist/osx-x64/BTCPayApp.app
# continue-on-error: true
# - name: Verify arm64 app bundle signing
# run: spctl --assess --type execute dist/osx-arm64/BTCPayApp.app
# continue-on-error: true
# # Create DMG files
# - name: Create DMG for x64
# run: |
# mkdir -p dmg
# hdiutil create -size 1gb -volname "BTCPayApp-osx-x64" -srcfolder "dist/osx-x64" -ov -format UDZO dmg/BTCPayApp-x64.dmg
# codesign --sign "$CERT_NAME" --deep --force --options runtime dmg/BTCPayApp-x64.dmg
# codesign --verify --deep --strict dmg/BTCPayApp-x64.dmg
# - name: Create DMG for arm64
# run: |
# mkdir -p dmg
# hdiutil create -size 1gb -volname "BTCPayApp-osx-arm64" -srcfolder "dist/osx-arm64" -ov -format UDZO dmg/BTCPayApp-arm64.dmg
# codesign --sign "$CERT_NAME" --deep --force --options runtime dmg/BTCPayApp-arm64.dmg
# codesign --verify --deep --strict dmg/BTCPayApp-arm64.dmg
# # Verify DMG signing
# - name: Verify x64 DMG signing
# run: spctl --assess --type execute dmg/BTCPayApp-x64.dmg
# continue-on-error: true
# - name: Verify arm64 DMG signing
# run: spctl --assess --type execute dmg/BTCPayApp-arm64.dmg
# continue-on-error: true
# # Upload artifacts
# - name: Upload DMG artifacts
# uses: actions/upload-artifact@v4
# with:
# name: mac-dmg
# path: dmg
#
# build-ios:
# runs-on: macos-latest
# steps:
# - uses: actions/checkout@v4
# with:
# submodules: recursive
# - name: Setup Xcode version
# uses: maxim-lobanov/setup-xcode@v1.6.0
# with:
# xcode-version: 16.0
# - name: Setup .NET
# uses: actions/setup-dotnet@v4
# with:
# dotnet-version: 8.0.x
# - name: Install workloads
# run: dotnet workload install maui
# - name: Import Code-Signing Certificates
# uses: Apple-Actions/import-codesign-certs@v1
# with:
# p12-file-base64: ${{ secrets.APPLE_CERT_P12_BASE64 }}
# p12-password: ${{ secrets.APPLE_CERT_P12_PASS }}
# - name: Download Apple Provisioning Profiles
# uses: Apple-Actions/download-provisioning-profiles@v1
# with:
# bundle-id: ${{ secrets.APPLE_BUNDLE_ID }}
# issuer-id: ${{ secrets.APPLE_ISSUER_ID }}
# api-key-id: ${{ secrets.APPLE_KEY_ID }}
# api-private-key: ${{ secrets.APPLE_KEY }}
# - name: Build
# run: dotnet publish BTCPayApp.Maui/BTCPayApp.Maui.csproj -f net8.0-ios -c Debug -o publish/ios
# - name: Upload artifact
# uses: actions/upload-artifact@v4
# with:
# name: ios build
# path: |
# publish/ios

51
.github/workflows/desktop.yml vendored Normal file
View File

@ -0,0 +1,51 @@
name: 'Test the desktop app'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
- '**/*.gitignore'
- '**/*.gitattributes'
pull_request:
branches:
- master
env:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
# https://blog.taranissoftware.com/build-net-maui-apps-with-github-actions
jobs:
build:
runs-on: ubuntu-latest
steps:
# Setup code, .NET and Android
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Build
run: dotnet build --configuration Release BTCPayApp.Server
# E2E tests
- name: Start containers
run: docker-compose -f "submodules/btcpayserver/BTCPayServer.Tests/docker-compose.yml" up -d dev
- name: Start BTCPay
run: |
cd submodules/btcpayserver
nohup dotnet run -c Release --project BTCPayServer &
while ! curl -s http://localhost:14142/api/v1/health > /dev/null; do
echo "Waiting for BTCPay Server to start..."
sleep 10
done
# Unit and integration tests
- name: Run fast tests
run: dotnet test -v n --logger "console;verbosity=normal" --filter "Fast=Fast" BTCPayApp.Tests
- name: Run integration tests
run: dotnet test -v n --logger "console;verbosity=normal" --filter "Integration=Integration" BTCPayApp.Tests
- name: Run Selenium tests
run: dotnet test --filter "Selenium=Selenium" -v n --logger "console;verbosity=normal" BTCPayApp.Tests

51
.github/workflows/maui.yml vendored Normal file
View File

@ -0,0 +1,51 @@
name: 'Test the mobile app'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
- '**/*.gitignore'
- '**/*.gitattributes'
pull_request:
branches:
- master
env:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
# https://blog.taranissoftware.com/build-net-maui-apps-with-github-actions
jobs:
build:
runs-on: windows-latest
steps:
# Setup code, .NET and Android
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Set up JDK
uses: actions/setup-java@v3
with:
java-version: '22'
distribution: 'temurin'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
# Restore and build
- name: Install workloads
run: dotnet workload install maui --ignore-failed-sources
- name: Install dependencies
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
# Unit and integration tests
- name: Run fast tests
run: dotnet test --no-restore -v n --logger "console;verbosity=normal" --filter "Fast=Fast"
- name: Run integration tests
run: dotnet test --no-restore -v n --logger "console;verbosity=normal" --filter "Integration=Integration"

3
.gitignore vendored
View File

@ -1,7 +1,6 @@
.idea
**/bin
**/obj
**/tmp
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
@ -39,8 +38,6 @@ bld/
# Visual Studio 2015/2017 cache/options directory
.vs/
.vscode/
.DS_Store
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/

View File

@ -1,17 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="App-Server 2nd Instance" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/BTCPayApp.Server/BTCPayApp.Server.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="https-second-instance" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

View File

@ -1,17 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="App-Server Mutinynet" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/BTCPayApp.Server/BTCPayApp.Server.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="mutinynet" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

View File

@ -1,18 +0,0 @@
·<component name="ProjectRunConfigurationManager">
<configuration default="false" name="BTCPayApp.Maui" type="XamarinAndroidProject" factoryName="Xamarin.Android">
<option name="PROJECT_PATH" value="$PROJECT_DIR$/BTCPayApp.Maui/BTCPayApp.Maui.csproj" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
<option name="DEPLOY_BEHAVIOUR_NAME" value="Default" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration default="false" name="BTCPayApp.Maui" type="XamarinIOSProject" factoryName="Xamarin.iOS">
<option name="PROJECT_PATH" value="$PROJECT_DIR$/BTCPayApp.Maui/BTCPayApp.Maui.csproj" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="IS_PASS_PARENT_ENVS" value="false" />
<option name="EXTRA_MLAUNCH_PARAMETERS" value="" />
<method v="2" />
</configuration>
</component>

View File

@ -1,9 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="DEV ALL WITH SECOND APP" type="CompoundRunConfigurationType">
<toRun name="App-Server" type="LaunchSettings" />
<toRun name="App-Server 2nd Instance" type="LaunchSettings" />
<toRun name="Server Regtest" type="LaunchSettings" />
<toRun name="Docker compose" type="docker-deploy" />
<method v="2" />
</configuration>
</component>

View File

@ -1,8 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="DEV ALL testnet" type="CompoundRunConfigurationType">
<toRun name="App-Server" type="LaunchSettings" />
<toRun name="Server Testnet" type="LaunchSettings" />
<toRun name="Docker compose testnet" type="docker-deploy" />
<method v="2" />
</configuration>
</component>

View File

@ -1,7 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="DEV ALL" type="CompoundRunConfigurationType">
<toRun name="App-Server" type="LaunchSettings" />
<toRun name="Server Regtest" type="LaunchSettings" />
<toRun name="Server" type="LaunchSettings" />
<toRun name="Docker compose" type="docker-deploy" />
<method v="2" />
</configuration>

View File

@ -1,7 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="DEV ANDROID" type="CompoundRunConfigurationType">
<toRun name="Server Regtest" type="LaunchSettings" />
<toRun name="Docker compose" type="docker-deploy" />
<method v="2" />
</configuration>
</component>

View File

@ -1,17 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Docker compose mutiny" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker">
<deployment type="docker-compose.yml">
<settings>
<option name="envFilePath" value="" />
<option name="services">
<list>
<option value="dev" />
</list>
</option>
<option name="sourceFilePath" value="submodules/btcpayserver/BTCPayServer.Tests/docker-compose.mutinynet.yml" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
</component>

View File

@ -1,17 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Docker compose testnet" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker">
<deployment type="docker-compose.yml">
<settings>
<option name="envFilePath" value="" />
<option name="services">
<list>
<option value="dev" />
</list>
</option>
<option name="sourceFilePath" value="submodules/btcpayserver/BTCPayServer.Tests/docker-compose.testnet.yml" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
</component>

View File

@ -1,17 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Server Mutinynet" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/submodules/btcpayserver/BTCPayServer/BTCPayServer.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="Bitcoin-HTTPS-MUTINYNET" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

View File

@ -1,19 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Server Regtest" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/submodules/btcpayserver/BTCPayServer/BTCPayServer.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="Bitcoin-HTTPS" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" default="false" projectName="BTCPayApp.Core" projectPath="$PROJECT_DIR$/BTCPayApp.Core/BTCPayApp.Core.csproj" />
<option name="Build" default="false" projectName="BTCPayServer.Plugins.App" projectPath="$PROJECT_DIR$/BTCPayServer.Plugins.App/BTCPayServer.Plugins.App.csproj" />
<option name="Build" />
</method>
</configuration>
</component>

View File

@ -1,17 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Server Testnet" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/submodules/btcpayserver/BTCPayServer/BTCPayServer.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="Bitcoin-HTTPS-TESTNET" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

View File

@ -1,26 +0,0 @@
using BTCPayApp.Core.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace BTCPayApp.Core;
public class AppDatabaseMigrator(ILogger<AppDatabaseMigrator> logger, IDbContextFactory<AppDbContext> dbContextFactory) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var pendingMigrationsAsync = (await dbContext.Database.GetPendingMigrationsAsync(cancellationToken: cancellationToken)).ToArray();
if (pendingMigrationsAsync.Length != 0)
{
logger.LogInformation("Applying {Length} migrations", pendingMigrationsAsync.Length);
await dbContext.Database.MigrateAsync(cancellationToken);
logger.LogInformation("Migrations applied: {Migrations}", string.Join(", ", pendingMigrationsAsync));
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,45 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text.Json;
namespace BTCPayApp.Core.AspNetRip;
/// <summary>
/// The JSON data transfer object for the bearer token response typically found in "/login" and "/refresh" responses.
/// </summary>
public sealed class AccessTokenResponse
{
/// <summary>
/// The value is always "Bearer" which indicates this response provides a "Bearer" token
/// in the form of an opaque <see cref="AccessToken"/>.
/// </summary>
/// <remarks>
/// This is serialized as "tokenType": "Bearer" using <see cref="JsonSerializerDefaults.Web"/>.
/// </remarks>
public string TokenType { get; } = "Bearer";
/// <summary>
/// The opaque bearer token to send as part of the Authorization request header.
/// </summary>
/// <remarks>
/// This is serialized as "accessToken": "{AccessToken}" using <see cref="JsonSerializerDefaults.Web"/>.
/// </remarks>
public required string AccessToken { get; init; }
/// <summary>
/// The number of seconds before the <see cref="AccessToken"/> expires.
/// </summary>
/// <remarks>
/// This is serialized as "expiresIn": "{ExpiresInSeconds}" using <see cref="JsonSerializerDefaults.Web"/>.
/// </remarks>
public required long ExpiresIn { get; init; }
/// <summary>
/// If set, this provides the ability to get a new access_token after it expires using a refresh endpoint.
/// </summary>
/// <remarks>
/// This is serialized as "refreshToken": "{RefreshToken}" using using <see cref="JsonSerializerDefaults.Web"/>.
/// </remarks>
public required string RefreshToken { get; init; }
}

View File

@ -0,0 +1,29 @@
namespace BTCPayApp.Core.AspNetRip;
/// <summary>
/// The request type for the "/login" endpoint added by <see cref="IdentityApiEndpointRouteBuilderExtensions.MapIdentityApi"/>.
/// </summary>
public sealed class LoginRequest
{
/// <summary>
/// The user's email address which acts as a user name.
/// </summary>
public required string Email { get; init; }
/// <summary>
/// The user's password.
/// </summary>
public required string Password { get; init; }
/// <summary>
/// The optional two-factor authenticator code. This may be required for users who have enabled two-factor authentication.
/// This is not required if a <see cref="TwoFactorRecoveryCode"/> is sent.
/// </summary>
public string? TwoFactorCode { get; init; }
/// <summary>
/// An optional two-factor recovery code from <see cref="TwoFactorResponse.RecoveryCodes"/>.
/// This is required for users who have enabled two-factor authentication but lost access to their <see cref="TwoFactorCode"/>.
/// </summary>
public string? TwoFactorRecoveryCode { get; init; }
}

View File

@ -0,0 +1,71 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text.Json.Serialization;
namespace BTCPayApp.Core.AspNetRip;
/// <summary>
/// A machine-readable format for specifying errors in HTTP API responses based on <see href="https://tools.ietf.org/html/rfc7807"/>.
/// </summary>
public class ProblemDetails
{
/// <summary>
/// A URI reference [RFC3986] that identifies the problem type. This specification encourages that, when
/// dereferenced, it provide human-readable documentation for the problem type
/// (e.g., using HTML [W3C.REC-html5-20141028]). When this member is not present, its value is assumed to be
/// "about:blank".
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyOrder(-5)]
[JsonPropertyName("type")]
public string? Type { get; set; }
/// <summary>
/// A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence
/// of the problem, except for purposes of localization(e.g., using proactive content negotiation;
/// see[RFC7231], Section 3.4).
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyOrder(-4)]
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>
/// The HTTP status code([RFC7231], Section 6) generated by the origin server for this occurrence of the problem.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyOrder(-3)]
[JsonPropertyName("status")]
public int? Status { get; set; }
/// <summary>
/// A human-readable explanation specific to this occurrence of the problem.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyOrder(-2)]
[JsonPropertyName("detail")]
public string? Detail { get; set; }
/// <summary>
/// A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyOrder(-1)]
[JsonPropertyName("instance")]
public string? Instance { get; set; }
/// <summary>
/// Gets the <see cref="IDictionary{TKey, TValue}"/> for extension members.
/// <para>
/// Problem type definitions MAY extend the problem details object with additional members. Extension members appear in the same namespace as
/// other members of a problem type.
/// </para>
/// </summary>
/// <remarks>
/// The round-tripping behavior for <see cref="Extensions"/> is determined by the implementation of the Input \ Output formatters.
/// In particular, complex types or collection types may not round-trip to the original type when using the built-in JSON or XML formatters.
/// </remarks>
[JsonExtensionData]
public IDictionary<string, object?> Extensions { get; set; } = new Dictionary<string, object?>(StringComparer.Ordinal);
}

View File

@ -0,0 +1,13 @@
namespace BTCPayApp.Core.AspNetRip;
/// <summary>
/// The request type for the "/refresh" endpoint added by <see cref="IdentityApiEndpointRouteBuilderExtensions.MapIdentityApi"/>.
/// </summary>
public sealed class RefreshRequest
{
/// <summary>
/// The <see cref="AccessTokenResponse.RefreshToken"/> from the last "/login" or "/refresh" response used to get a new <see cref="AccessTokenResponse"/>
/// with an extended expiration.
/// </summary>
public required string RefreshToken { get; init; }
}

View File

@ -0,0 +1,23 @@
namespace BTCPayApp.Core.AspNetRip;
/// <summary>
/// The response type for the "/resetPassword" endpoint added by <see cref="IdentityApiEndpointRouteBuilderExtensions.MapIdentityApi"/>.
/// The "/resetPassword" endpoint requires the "/forgotPassword" endpoint to be called first to get the <see cref="ResetCode"/>.
/// </summary>
public sealed class ResetPasswordRequest
{
/// <summary>
/// The email address for the user requesting a password reset. This should match <see cref="ForgotPasswordRequest.Email"/>.
/// </summary>
public required string Email { get; init; }
/// <summary>
/// The code sent to the user's email to reset the password. To get the reset code, first make a "/forgotPassword" request.
/// </summary>
public required string ResetCode { get; init; }
/// <summary>
/// The new password the user with the given <see cref="Email"/> should login with. This will replace the previous password.
/// </summary>
public required string NewPassword { get; init; }
}

View File

@ -0,0 +1,46 @@
using BTCPayApp.Core.Data;
using BTCPayServer.Lightning;
namespace BTCPayApp.Core.Attempt2;
public static class AppToServerHelper
{
public static LightningInvoice ToInvoice(this AppLightningPayment lightningPayment)
{
return new LightningInvoice()
{
Id = lightningPayment.PaymentHash.ToString(),
Amount = lightningPayment.Value,
PaymentHash = lightningPayment.PaymentHash.ToString(),
Preimage = lightningPayment.Preimage,
PaidAt = lightningPayment.Status == LightningPaymentStatus.Complete? DateTimeOffset.UtcNow: null, //TODO: store these in ln payment
BOLT11 = lightningPayment.PaymentRequest.ToString(),
Status = lightningPayment.Status == LightningPaymentStatus.Complete? LightningInvoiceStatus.Paid: lightningPayment.PaymentRequest.ExpiryDate < DateTimeOffset.UtcNow? LightningInvoiceStatus.Expired: LightningInvoiceStatus.Unpaid
};
}
public static LightningPayment ToPayment(this AppLightningPayment lightningPayment)
{
return new LightningPayment()
{
Id = lightningPayment.PaymentHash.ToString(),
Amount = LightMoney.MilliSatoshis(lightningPayment.Value),
PaymentHash = lightningPayment.PaymentHash.ToString(),
Preimage = lightningPayment.Preimage,
BOLT11 = lightningPayment.PaymentRequest.ToString(),
Status = lightningPayment.Status
};
}
public static async Task<List<LightningPayment>> ToPayments(this Task<List<AppLightningPayment>> appLightningPayments)
{
var result = await appLightningPayments;
return result.Select(ToPayment).ToList();
}
public static async Task<List<LightningInvoice>> ToInvoices(this Task<List<AppLightningPayment>> appLightningPayments)
{
var result = await appLightningPayments;
return result.Select(ToInvoice).ToList();
}
}

View File

@ -0,0 +1,117 @@
using System.Text;
using BTCPayApp.CommonServer;
using BTCPayApp.Core.Helpers;
using BTCPayServer.Client.Models;
using BTCPayServer.Lightning;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.Crypto;
namespace BTCPayApp.Core.Attempt2;
public class BTCPayAppServerClient(ILogger<BTCPayAppServerClient> _logger, IServiceProvider _serviceProvider) : IBTCPayAppHubClient
{
public event AsyncEventHandler<string>? OnNewBlock;
public event AsyncEventHandler<TransactionDetectedRequest>? OnTransactionDetected;
public event AsyncEventHandler<string>? OnNotifyNetwork;
public event AsyncEventHandler<string>? OnServerNodeInfo;
public event AsyncEventHandler<ServerEvent>? OnNotifyServerEvent;
public async Task NotifyServerEvent(ServerEvent ev)
{
_logger.LogInformation("NotifyServerEvent: {ev}", ev);
await OnNotifyServerEvent?.Invoke(this, ev);
}
public async Task NotifyNetwork(string network)
{
_logger.LogInformation("NotifyNetwork: {network}", network);
await OnNotifyNetwork?.Invoke(this, network);
}
public async Task NotifyServerNode(string nodeInfo)
{
_logger.LogInformation("NotifyServerNode: {nodeInfo}", nodeInfo);
await OnServerNodeInfo?.Invoke(this, nodeInfo);
}
public async Task TransactionDetected(TransactionDetectedRequest request)
{
_logger.LogInformation($"OnTransactionDetected: {request.TxId}");
await OnTransactionDetected?.Invoke(this, request);
}
public async Task NewBlock(string block)
{
_logger.LogInformation("NewBlock: {block}", block);
await OnNewBlock?.Invoke(this, block);
}
private PaymentsManager PaymentsManager =>
_serviceProvider.GetRequiredService<LightningNodeManager>().Node.PaymentsManager;
public async Task<LightningInvoice> CreateInvoice(CreateLightningInvoiceRequest createLightningInvoiceRequest)
{
var descHash = new uint256(Hashes.SHA256(Encoding.UTF8.GetBytes(createLightningInvoiceRequest.Description)),
false);
return (await PaymentsManager.RequestPayment(createLightningInvoiceRequest.Amount,
createLightningInvoiceRequest.Expiry, descHash)).ToInvoice();
}
public async Task<LightningInvoice?> GetLightningInvoice(uint256 paymentHash)
{
var invs = await PaymentsManager.List(payments =>
payments.Where(payment => payment.Inbound && payment.PaymentHash == paymentHash));
return invs.FirstOrDefault()?.ToInvoice();
}
public async Task<LightningPayment?> GetLightningPayment(uint256 paymentHash)
{
var invs = await PaymentsManager.List(payments =>
payments.Where(payment => !payment.Inbound && payment.PaymentHash == paymentHash));
return invs.FirstOrDefault()?.ToPayment();
}
public async Task<List<LightningPayment>> GetLightningPayments(ListPaymentsParams request)
{
return await PaymentsManager.List(payments => payments.Where(payment => !payment.Inbound), default).ToPayments();
}
public async Task<List<LightningInvoice>> GetLightningInvoices(ListInvoicesParams request)
{
return await PaymentsManager.List(payments => payments.Where(payment => payment.Inbound), default).ToInvoices();
}
public async Task<PayResponse> PayInvoice(string bolt11, long? amountMilliSatoshi)
{
var network = _serviceProvider.GetRequiredService<OnChainWalletManager>().Network;
var bolt = BOLT11PaymentRequest.Parse(bolt11, network);
try
{
var result = await PaymentsManager.PayInvoice(bolt,
amountMilliSatoshi is null ? null : LightMoney.MilliSatoshis(amountMilliSatoshi.Value));
return new PayResponse()
{
Result = result.Status switch
{
LightningPaymentStatus.Unknown => PayResult.Unknown,
LightningPaymentStatus.Pending => PayResult.Unknown,
LightningPaymentStatus.Complete => PayResult.Ok,
LightningPaymentStatus.Failed => PayResult.Error,
_ => throw new ArgumentOutOfRangeException()
},
Details = new PayDetails()
{
Preimage = result.Preimage is not null ? new uint256(result.Preimage) : null,
Status = result.Status
}
};
}
catch (Exception e)
{
_logger.LogError(e, "Error paying invoice");
return new PayResponse(PayResult.Error, e.Message);
}
}
}

View File

@ -0,0 +1,254 @@
using System.Net;
using BTCPayApp.CommonServer;
using BTCPayApp.Core.Auth;
using BTCPayApp.Core.Contracts;
using BTCPayApp.Core.Helpers;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NBitcoin;
using TypedSignalR.Client;
namespace BTCPayApp.Core.Attempt2;
public class BTCPayConnectionManager : IHostedService, IHubConnectionObserver
{
private const string ConfigDeviceIdentifierKey = "deviceIdentifier";
private readonly IAccountManager _accountManager;
private readonly AuthenticationStateProvider _authStateProvider;
private readonly ILogger<BTCPayConnectionManager> _logger;
private readonly BTCPayAppServerClient _btcPayAppServerClient;
private readonly IBTCPayAppHubClient _btcPayAppServerClientInterface;
private readonly IConfigProvider _configProvider;
private readonly SyncService _syncService;
private IDisposable? _subscription;
public IBTCPayAppHubServer? HubProxy { get; private set; }
private HubConnection? Connection { get; set; }
public Network? ReportedNetwork { get; private set; }
public string ReportedNodeInfo { get; set; }
public event AsyncEventHandler<(BTCPayConnectionState Old, BTCPayConnectionState New)>? ConnectionChanged;
private BTCPayConnectionState _connectionState = BTCPayConnectionState.Init;
public BTCPayConnectionState ConnectionState
{
get => _connectionState;
private set
{
if (_connectionState == value)
return;
var old = _connectionState;
_connectionState = value;
_logger.LogInformation("Connection state changed: {State}", _connectionState);
ConnectionChanged?.Invoke(this, (old, _connectionState));
}
}
public BTCPayConnectionManager(
IAccountManager accountManager,
AuthenticationStateProvider authStateProvider,
ILogger<BTCPayConnectionManager> logger,
BTCPayAppServerClient btcPayAppServerClient,
IBTCPayAppHubClient btcPayAppServerClientInterface,
IConfigProvider configProvider,
SyncService syncService)
{
_accountManager = accountManager;
_authStateProvider = authStateProvider;
_logger = logger;
_btcPayAppServerClient = btcPayAppServerClient;
_btcPayAppServerClientInterface = btcPayAppServerClientInterface;
_configProvider = configProvider;
_syncService = syncService;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
ConnectionChanged += OnConnectionChanged;
_authStateProvider.AuthenticationStateChanged += OnAuthenticationStateChanged;
_btcPayAppServerClient.OnNotifyNetwork += OnNotifyNetwork;
_btcPayAppServerClient.OnNotifyServerEvent += OnNotifyServerEvent;
_btcPayAppServerClient.OnServerNodeInfo += OnServerNodeInfo;
await OnConnectionChanged(this, (BTCPayConnectionState.Init, BTCPayConnectionState.Init));
}
private async Task<long> GetDeviceIdentifier()
{
return await _configProvider.GetOrSet(ConfigDeviceIdentifierKey,
async () => RandomUtils.GetInt64(), false);
}
private async Task OnConnectionChanged(object? sender, (BTCPayConnectionState Old, BTCPayConnectionState New) e)
{
var account = _accountManager.GetAccount();
switch (e.New)
{
case BTCPayConnectionState.Init:
ConnectionState = BTCPayConnectionState.WaitingForAuth;
break;
case BTCPayConnectionState.WaitingForAuth:
await Kill();
if (account is not null)
{
ConnectionState = BTCPayConnectionState.Connecting;
}
break;
case BTCPayConnectionState.Connecting:
if (account is null)
{
ConnectionState = BTCPayConnectionState.WaitingForAuth;
break;
}
if (Connection is null)
{
Connection = new HubConnectionBuilder()
.AddNewtonsoftJsonProtocol(options =>
{
NBitcoin.JsonConverters.Serializer.RegisterFrontConverters(
options.PayloadSerializerSettings);
})
.WithUrl(new Uri(new Uri(account.BaseUri), "hub/btcpayapp").ToString(),
options =>
{
options.AccessTokenProvider = () =>
Task.FromResult(_accountManager.GetAccount()?.AccessToken);
})
.WithAutomaticReconnect()
.Build();
_subscription = Connection.Register(_btcPayAppServerClientInterface);
HubProxy = Connection.CreateHubProxy<IBTCPayAppHubServer>();
}
if (Connection.State == HubConnectionState.Disconnected)
{
try
{
await Connection.StartAsync();
if (Connection.State == HubConnectionState.Connected)
{
ConnectionState = BTCPayConnectionState.Syncing;
}
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized)
{
await _accountManager.RefreshAccess();
ConnectionState = BTCPayConnectionState.WaitingForAuth;
}
}
break;
case BTCPayConnectionState.Syncing:
await _syncService.SyncToLocal();
ConnectionState = BTCPayConnectionState.ConnectedFinishedInitialSync;
break;
case BTCPayConnectionState.ConnectedFinishedInitialSync:
var deviceIdentifier = await GetDeviceIdentifier();
var master = await HubProxy.DeviceMasterSignal(deviceIdentifier, true);
ConnectionState =
master ? BTCPayConnectionState.ConnectedAsMaster : BTCPayConnectionState.ConnectedAsSlave;
break;
case BTCPayConnectionState.ConnectedAsMaster:
await _syncService.StartSync(false, await GetDeviceIdentifier());
break;
case BTCPayConnectionState.ConnectedAsSlave:
await _syncService.StartSync(true, await GetDeviceIdentifier());
break;
case BTCPayConnectionState.Disconnected:
ConnectionState = BTCPayConnectionState.WaitingForAuth;
break;
}
}
private async Task OnServerNodeInfo(object? sender, string e)
{
ReportedNodeInfo = e;
}
private async Task OnNotifyServerEvent(object? sender, ServerEvent e)
{
_logger.LogInformation("OnNotifyServerEvent: {Type} - {Details}", e.Type, e.ToString());
}
private async Task OnNotifyNetwork(object? sender, string e)
{
ReportedNetwork = Network.GetNetwork(e);
}
private async void OnAuthenticationStateChanged(Task<AuthenticationState> task)
{
try
{
await task;
var authenticated = await _accountManager.CheckAuthenticated();
await Kill();
ConnectionState = !authenticated ? BTCPayConnectionState.WaitingForAuth : BTCPayConnectionState.Connecting;
}
catch (Exception e)
{
_logger.LogError(e, "Error while handling authentication state change");
}
}
private async Task Kill()
{
var conn = Connection;
Connection = null;
if (conn is not null)
await conn.StopAsync();
_subscription?.Dispose();
_subscription = null;
HubProxy = null;
await _syncService.StopSync();
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_connectionState == BTCPayConnectionState.ConnectedAsMaster)
{
_logger.LogInformation("Sending device master signal to turn off");
var deviceIdentifier = await GetDeviceIdentifier();
await HubProxy.DeviceMasterSignal(deviceIdentifier, true);
}
await Kill();
_authStateProvider.AuthenticationStateChanged -= OnAuthenticationStateChanged;
_btcPayAppServerClient.OnNotifyNetwork += OnNotifyNetwork;
ConnectionChanged -= OnConnectionChanged;
}
public Task OnClosed(Exception? exception)
{
_logger.LogError(exception, "Hub connection closed");
if (Connection?.State == HubConnectionState.Disconnected)
{
ConnectionState = BTCPayConnectionState.Disconnected;
}
return Task.CompletedTask;
}
public async Task OnReconnected(string? connectionId)
{
_logger.LogInformation("Hub connection reconnected");
ConnectionState = BTCPayConnectionState.Syncing;
}
public async Task OnReconnecting(Exception? exception)
{
_logger.LogWarning(exception, "Hub connection reconnecting");
ConnectionState = BTCPayConnectionState.Connecting;
}
}

View File

@ -0,0 +1,13 @@
namespace BTCPayApp.Core.Attempt2;
public enum BTCPayConnectionState
{
Init,
WaitingForAuth,
Connecting,
Syncing,
Disconnected,
ConnectedAsMaster,
ConnectedAsSlave,
ConnectedFinishedInitialSync
}

View File

@ -0,0 +1,42 @@
using BTCPayApp.Core.Data;
using BTCPayApp.Core.Helpers;
using BTCPayApp.Core.LDK;
using BTCPayServer.Lightning;
namespace BTCPayApp.Core.Attempt2;
public class BTCPayPaymentsNotifier : IScopedHostedService
{
private readonly PaymentsManager _paymentsManager;
private readonly BTCPayConnectionManager _connectionManager;
private readonly OnChainWalletManager _onChainWalletManager;
public BTCPayPaymentsNotifier(
PaymentsManager paymentsManager, BTCPayConnectionManager connectionManager,
OnChainWalletManager onChainWalletManager)
{
_paymentsManager = paymentsManager;
_connectionManager = connectionManager;
_onChainWalletManager = onChainWalletManager;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_paymentsManager.OnPaymentUpdate += OnPaymentUpdate;
}
private async Task OnPaymentUpdate(object? sender, AppLightningPayment e)
{
await _connectionManager.HubProxy
.SendInvoiceUpdate(
_onChainWalletManager.WalletConfig.Derivations[WalletDerivation.LightningScripts].Identifier, e.ToInvoice())
.RunSync();
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_paymentsManager.OnPaymentUpdate -= OnPaymentUpdate;
}
}

View File

@ -0,0 +1,32 @@
using BTCPayApp.Core.Contracts;
namespace BTCPayApp.Core.Attempt2;
public static class ConfigHelpers
{
public static async Task<T> GetOrSet<T>(this ISecureConfigProvider secureConfigProvider, string key,
Func<Task<T>> factory)
{
var value = await secureConfigProvider.Get<T>(key);
if (Equals(value, default(T)))
{
value = await factory();
await secureConfigProvider.Set(key, value);
}
return value;
}
public static async Task<T> GetOrSet<T>(this IConfigProvider configProvider, string key, Func<Task<T>> factory,
bool backup)
{
var value = await configProvider.Get<T>(key);
if (Equals(value, default(T)))
{
value = await factory();
await configProvider.Set(key, value, backup);
}
return value;
}
}

View File

@ -0,0 +1,18 @@
using org.ldk.structs;
namespace BTCPayApp.Core.Attempt2;
public class LDKChangeDestinationSource:ChangeDestinationSourceInterface
{
private readonly LightningNodeManager _lightningNodeManager;
public LDKChangeDestinationSource( LightningNodeManager lightningNodeManager)
{
_lightningNodeManager = lightningNodeManager;
}
public Result_CVec_u8ZNoneZ get_change_destination_script()
{
var s = _lightningNodeManager.Node.DeriveScript().ConfigureAwait(false).GetAwaiter().GetResult();
return Result_CVec_u8ZNoneZ.ok(s.ToBytes());
}
}

View File

@ -2,10 +2,17 @@
using org.ldk.enums;
using org.ldk.structs;
namespace BTCPayApp.Core.LDK;
namespace BTCPayApp.Core.Attempt2;
public class LDKKVStore(ConfigProvider configProvider) : KVStoreInterface
public class LDKKVStore:KVStoreInterface
{
private readonly IConfigProvider _configProvider;
public LDKKVStore(IConfigProvider configProvider)
{
_configProvider = configProvider;
}
private string CombineKey(string primary_namespace, string secondary_namespace, string key)
{
var str = "ln:";
@ -28,28 +35,28 @@ public class LDKKVStore(ConfigProvider configProvider) : KVStoreInterface
public Result_CVec_u8ZIOErrorZ read(string primary_namespace, string secondary_namespace, string key)
{
var key1 = CombineKey(primary_namespace, secondary_namespace, key);
var result = configProvider.Get<byte[]>(key1).ConfigureAwait(false).GetAwaiter().GetResult();
var result = _configProvider.Get<byte[]>(key1).ConfigureAwait(false).GetAwaiter().GetResult();
return result == null ? Result_CVec_u8ZIOErrorZ.err(IOError.LDKIOError_NotFound) : Result_CVec_u8ZIOErrorZ.ok(result);
}
public Result_NoneIOErrorZ write(string primary_namespace, string secondary_namespace, string key, byte[] buf)
{
var key1 = CombineKey(primary_namespace, secondary_namespace, key);
configProvider.Set(key1, buf, true).ConfigureAwait(false).GetAwaiter().GetResult();
_configProvider.Set(key1, buf, true).ConfigureAwait(false).GetAwaiter().GetResult();
return Result_NoneIOErrorZ.ok();
}
public Result_NoneIOErrorZ remove(string primary_namespace, string secondary_namespace, string key, bool lazy)
{
var key1 = CombineKey(primary_namespace, secondary_namespace, key);
configProvider.Set<byte[]>(key1, null, true).ConfigureAwait(false).GetAwaiter().GetResult();
_configProvider.Set<byte[]>(key1, null, true).ConfigureAwait(false).GetAwaiter().GetResult();
return Result_NoneIOErrorZ.ok();
}
public Result_CVec_StrZIOErrorZ list(string primary_namespace, string secondary_namespace)
{
var key1 = CombineKey(primary_namespace, secondary_namespace, string.Empty);
var result = configProvider.List(key1).ConfigureAwait(false).GetAwaiter().GetResult();
var result = _configProvider.List(key1).ConfigureAwait(false).GetAwaiter().GetResult();
return Result_CVec_StrZIOErrorZ.ok(result.ToArray());
}
}
}

View File

@ -0,0 +1,401 @@
using System.Collections.Specialized;
using BTCPayApp.Core.Contracts;
using BTCPayApp.Core.Data;
using BTCPayApp.Core.Helpers;
using BTCPayApp.Core.LDK;
using BTCPayApp.Core.LSP.JIT;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NBitcoin;
using org.ldk.structs;
using OutPoint = NBitcoin.OutPoint;
using UInt128 = org.ldk.util.UInt128;
namespace BTCPayApp.Core.Attempt2;
public partial class LDKNode:
ILDKEventHandler<Event.Event_ChannelClosed>,
ILDKEventHandler<Event.Event_ChannelPending>,
ILDKEventHandler<Event.Event_ChannelReady>
{
public async Task<ChannelDetails[]> GetChannels(CancellationToken cancellationToken = default)
{
return await _memoryCache.GetOrCreateAsync(nameof(GetChannels), async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return ServiceProvider.GetRequiredService<ChannelManager>().list_channels();
}).WithCancellation(cancellationToken);
}
public async Task Handle(Event.Event_ChannelClosed evt)
{
_memoryCache.Remove(nameof(GetChannels));
}
public async Task Handle(Event.Event_ChannelPending @event)
{
_memoryCache.Remove(nameof(GetChannels));
}
public async Task Handle(Event.Event_ChannelReady @event)
{
_memoryCache.Remove(nameof(GetChannels));
}
public async Task<PeerDetails[]> GetPeers(CancellationToken cancellationToken = default)
{
return await _memoryCache.GetOrCreateAsync(nameof(GetPeers), async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return ServiceProvider.GetRequiredService<PeerManager>().list_peers();
}).WithCancellation(cancellationToken);
}
public void PeersChanged()
{
_memoryCache.Remove(nameof(GetPeers));
}
private void InvalidateCache()
{
_memoryCache.Remove(nameof(GetPeers));
_memoryCache.Remove(nameof(GetChannels));
}
public async Task<Result_ChannelIdAPIErrorZ> OpenChannel(Money amount, PubKey nodeId, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Opening channel with {nodeId} for {amount}", nodeId, amount);
var channelManager = ServiceProvider.GetRequiredService<ChannelManager>();
var entropySource = ServiceProvider.GetRequiredService<EntropySource>();
var userConfig = ServiceProvider.GetRequiredService<UserConfig>();
var temporaryChannelId = ChannelId.temporary_from_entropy_source(entropySource);
var userChannelId = new UInt128(temporaryChannelId.get_a().Take(16).ToArray());
try
{
return await Task.Run(() => channelManager.create_channel(nodeId.ToBytes(), amount.Satoshi, 0, userChannelId,
temporaryChannelId, userConfig), cancellationToken);
}
finally
{
_logger.LogInformation("finished (trying to) opening channel with {nodeId} for {amount}", nodeId, amount);
}
}
public async Task<IJITService?> GetJITLSPService()
{
var config = await GetConfig();
var lsp = config.JITLSP;
if(lsp is null)
{
return null;
}
var jits = ServiceProvider.GetServices<IJITService>();
return jits.FirstOrDefault(jit => jit.ProviderName == lsp);
}
}
public partial class LDKNode : IAsyncDisposable, IHostedService, IDisposable
{
private readonly IMemoryCache _memoryCache;
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly BTCPayConnectionManager _connectionManager;
private readonly ILogger _logger;
private readonly IConfigProvider _configProvider;
private readonly OnChainWalletManager _onChainWalletManager;
public LDKNode(
IMemoryCache cache,
IDbContextFactory<AppDbContext> dbContextFactory,
BTCPayConnectionManager connectionManager,
IServiceProvider serviceProvider,
LDKWalletLogger logger,
IConfigProvider configProvider,
OnChainWalletManager onChainWalletManager)
{
_memoryCache = cache;
_dbContextFactory = dbContextFactory;
_connectionManager = connectionManager;
_logger = logger;
_configProvider = configProvider;
_onChainWalletManager = onChainWalletManager;
ServiceProvider = serviceProvider;
}
private IServiceProvider ServiceProvider { get; }
private TaskCompletionSource? _started;
private readonly SemaphoreSlim _semaphore = new(1);
public Network Network => ServiceProvider.GetRequiredService<Network>();
public async Task StartAsync(CancellationToken cancellationToken)
{
bool exists;
try
{
await _semaphore.WaitAsync(cancellationToken);
exists = _started is not null;
_started ??= new TaskCompletionSource();
}
finally
{
_semaphore.Release();
}
if (exists)
{
await _started.Task;
return;
}
InvalidateCache();
_config = await _configProvider.Get<LightningConfig>(key: LightningConfig.Key)?? new LightningConfig();
_configLoaded.SetResult();
var keyPath = KeyPath.Parse(_config.LightningDerivationPath);
Seed = new Mnemonic( _onChainWalletManager.WalletConfig.Mnemonic).DeriveExtKey().Derive(keyPath).PrivateKey.ToBytes();
var services = ServiceProvider.GetServices<IScopedHostedService>();
_logger.LogInformation("Starting LDKNode services");
var bb = await _onChainWalletManager.GetBestBlock();
if (bb is null)
{
throw new InvalidOperationException("Best block could not be retrieved. Killing the startup");
}
foreach (var service in services)
{
_logger.LogInformation($"Starting {service.GetType().Name}");
await service.StartAsync(cancellationToken);
}
_started.SetResult();
_logger.LogInformation("LDKNode started");
}
private readonly TaskCompletionSource _configLoaded = new();
public async Task<LightningConfig> GetConfig()
{
await _configLoaded.Task;
return _config!;
}
public async Task<string[]> GetJITLSPs()
{
return ServiceProvider.GetServices<IJITService>().Select(jit => jit.ProviderName).ToArray();
}
public async Task UpdateConfig(LightningConfig config)
{
await _started.Task;
await _configProvider.Set(LightningConfig.Key, config, true);
_config = config;
ConfigUpdated?.Invoke(this, config);
}
public AsyncEventHandler<LightningConfig>? ConfigUpdated;
public byte[] Seed { get; private set; }
public PaymentsManager PaymentsManager => ServiceProvider.GetRequiredService<PaymentsManager>();
public LDKPeerHandler PeerHandler => ServiceProvider.GetRequiredService<LDKPeerHandler>();
public PubKey NodeId => new(ServiceProvider.GetRequiredService<ChannelManager>().get_our_node_id());
public async Task StopAsync(CancellationToken cancellationToken)
{
bool exists;
try
{
await _semaphore.WaitAsync(cancellationToken);
exists = _started is not null;
}
finally
{
_semaphore.Release();
}
if (!exists)
return;
// var identifier = _onChainWalletManager.WalletConfig.Derivations[WalletDerivation.LightningScripts].Identifier;
_logger.LogInformation("Stopping LDKNode services");
var services = ServiceProvider.GetServices<IScopedHostedService>();
var tasks = services.Select(async service =>
{
_logger.LogInformation($"Stopping {service.GetType().Name}");
await service.StopAsync(cancellationToken);
_logger.LogInformation($"Stopped {service.GetType().Name}");
}).ToArray();
await Task.WhenAll(tasks);
// _ = _connectionManager.HubProxy.DeviceMasterSignal(identifier, false).RunSync();
}
public void Dispose() => DisposeAsync().GetAwaiter().GetResult();
public async ValueTask DisposeAsync()
{
// await StopAsync(CancellationToken.None);
}
private readonly TaskCompletionSource<ChannelMonitor[]?> icm = new();
private LightningConfig? _config;
public async Task<ChannelMonitor[]> GetInitialChannelMonitors()
{
return await icm.Task;
}
private async Task<ChannelMonitor[]> GetInitialChannelMonitors(EntropySource entropySource,
SignerProvider signerProvider)
{
await using var db = await _dbContextFactory.CreateDbContextAsync();
var data = await db.LightningChannels.Select(channel => channel.Data)
.ToArrayAsync();
var channels = ChannelManagerHelper.GetInitialMonitors(data, entropySource, signerProvider);
icm.SetResult(channels);
return channels;
}
public async Task<byte[]?> GetRawChannelManager()
{
return await _configProvider.Get<byte[]>("ln:ChannelManager") ?? null;
}
public async Task UpdateChannelManager(ChannelManager serializedChannelManager)
{
await _configProvider.Set("ln:ChannelManager", serializedChannelManager.write(), true);
}
public async Task UpdateNetworkGraph(NetworkGraph networkGraph)
{
await _configProvider.Set("ln:NetworkGraph", networkGraph.write(), true);
}
public async Task UpdateScore(WriteableScore score)
{
await _configProvider.Set("ln:Score", score.write(), true);
}
public async Task<(byte[] serializedChannelManager, ChannelMonitor[] channelMonitors)?> GetSerializedChannelManager(
EntropySource entropySource, SignerProvider signerProvider)
{
var data = await GetRawChannelManager();
if (data is null)
{
icm.SetResult(Array.Empty<ChannelMonitor>());
return null;
}
var channels = await GetInitialChannelMonitors(entropySource, signerProvider);
return (data, channels);
}
public async Task<Script> DeriveScript()
{
var derivationKey = (await GetConfig()).ScriptDerivationKey;
return await _onChainWalletManager.DeriveScript(derivationKey);
}
public async Task TrackScripts(Script[] scripts, string derivation = WalletDerivation.LightningScripts)
{
try
{
_logger.LogDebug("Tracking scripts {scripts}", string.Join(",", scripts.Select(script => script.ToHex())));
var identifier = _onChainWalletManager.WalletConfig.Derivations[derivation].Identifier;
await _connectionManager.HubProxy.TrackScripts(identifier,
scripts.Select(script => script.ToHex()).ToArray()).RunSync();
_logger.LogDebug("Tracked scripts {scripts}", string.Join(",", scripts.Select(script => script.ToHex())));
}
catch (Exception e)
{
_logger.LogError(e, "Error tracking scripts {scripts}", string.Join(",", scripts.Select(script => script.ToHex())));
}
}
public async Task UpdateChannel(List<ChannelAlias> identifiers, byte[] write)
{
var ids = identifiers.Select(alias => alias.Id).ToArray();
await using var context = await _dbContextFactory.CreateDbContextAsync();
var channel = (await context.ChannelAliases.Include(alias => alias.Channel)
.ThenInclude(channel1 => channel1.Aliases).FirstOrDefaultAsync(alias => ids.Contains(alias.Id)))?.Channel;
if (channel is not null)
{
foreach (var alias in identifiers)
{
if (channel.Aliases.All(a => a.Id != alias.Id))
{
channel.Aliases.Add(alias);
}
}
channel.Data = write;
}
else
{
await context.LightningChannels.AddAsync(new Channel()
{
Id = identifiers.First().ChannelId,
Data = write,
Aliases = identifiers.ToList()
});
}
await context.SaveChangesAsync();
}
public async Task Peer(PubKey key, PeerInfo? value)
{
var toString = key.ToString().ToLowerInvariant();
var config = await GetConfig();
if (value is null)
{
if (config.Peers.Remove(toString))
{
await UpdateConfig(config);
return;
}
}
config.Peers.AddOrReplace(toString, value);
await UpdateConfig(config);
}
}

View File

@ -0,0 +1,257 @@
using BTCPayApp.Core.Auth;
using BTCPayApp.Core.Data;
using BTCPayApp.Core.Helpers;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace BTCPayApp.Core.Attempt2;
public class LightningNodeManager : BaseHostedService
{
public const string PaymentMethodId = "BTC-LN";
private readonly IAccountManager _accountManager;
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly ILogger<LightningNodeManager> _logger;
private readonly OnChainWalletManager _onChainWalletManager;
private readonly BTCPayConnectionManager _btcPayConnectionManager;
private readonly IServiceScopeFactory _serviceScopeFactory;
private IServiceScope? _nodeScope;
public LDKNode? Node => _nodeScope?.ServiceProvider.GetService<LDKNode>();
private LightningNodeState _state = LightningNodeState.Init;
private bool IsHubConnected => _btcPayConnectionManager.ConnectionState is BTCPayConnectionState.ConnectedAsMaster;
private bool IsOnchainConfigured => _onChainWalletManager.WalletConfig is not null;
private bool IsOnchainLightningDerivationConfigured => _onChainWalletManager.WalletConfig?.Derivations.ContainsKey(WalletDerivation.LightningScripts) is true;
public bool CanConfigureLightningNode => IsHubConnected && IsOnchainConfigured && !IsOnchainLightningDerivationConfigured && State == LightningNodeState.NotConfigured;
public string? ConnectionString => IsOnchainLightningDerivationConfigured && _accountManager.GetUserInfo() is {} acc
? $"type=app;user={acc.UserId}": null;
public LightningNodeState State
{
get => _state;
private set
{
if (_state == value)
return;
var old = _state;
_state = value;
_logger.LogInformation("Lightning node state changed: {State}", _state);
StateChanged?.Invoke(this, (old, value));
}
}
public event AsyncEventHandler<(LightningNodeState Old, LightningNodeState New)>? StateChanged;
public LightningNodeManager(
IAccountManager accountManager,
IDbContextFactory<AppDbContext> dbContextFactory,
ILogger<LightningNodeManager> logger,
OnChainWalletManager onChainWalletManager,
BTCPayConnectionManager btcPayConnectionManager,
IServiceScopeFactory serviceScopeFactory)
{
_accountManager = accountManager;
_dbContextFactory = dbContextFactory;
_logger = logger;
_onChainWalletManager = onChainWalletManager;
_btcPayConnectionManager = btcPayConnectionManager;
_serviceScopeFactory = serviceScopeFactory;
}
public async Task StartNode()
{
if (_nodeScope is not null || State is LightningNodeState.Loaded)
return;
await _controlSemaphore.WaitAsync();
try
{
if (_nodeScope is null)
{
_nodeScope = _serviceScopeFactory.CreateScope();
_cancellationTokenSource = new CancellationTokenSource();
}
await Node.StartAsync(_cancellationTokenSource.Token);
State = LightningNodeState.Loaded;
}
catch (Exception e)
{
_nodeScope.Dispose();
_logger.LogError(e, "Error while starting lightning node");
_nodeScope = null;
State = LightningNodeState.Error;
}
finally
{
_controlSemaphore.Release();
}
}
public async Task StopNode()
{
if (_nodeScope is null || State is not LightningNodeState.Loaded)
return;
await _controlSemaphore.WaitAsync();
try
{
var cts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationTokenSource.Token);
cts.CancelAfter(5000);
await Node.StopAsync(cts.Token);
}
catch (Exception e)
{
_logger.LogError(e, "Error while stopping lightning node");
}
finally
{
_nodeScope?.Dispose();
_nodeScope = null;
_controlSemaphore.Release();
State = LightningNodeState.Stopped;
}
}
public async Task CleanseTask()
{
await StopNode();
if (_nodeScope is not null || State == LightningNodeState.NotConfigured) return;
await _controlSemaphore.WaitAsync();
try
{
await _onChainWalletManager.RemoveDerivation(WalletDerivation.LightningScripts);
await using var context = await _dbContextFactory.CreateDbContextAsync();
context.LightningPayments.RemoveRange(context.LightningPayments);
// context.OutboxItems.RemoveRange(context.OutboxItems);
context.Settings.RemoveRange(context.Settings.Where(s => s.Key.StartsWith("ln:")));
await context.SaveChangesAsync();
}
finally
{
_controlSemaphore.Release();
State = LightningNodeState.NotConfigured;
}
}
public async Task Generate()
{
await _controlSemaphore.WaitAsync();
try
{
if (State != LightningNodeState.NotConfigured) return;
if (!IsHubConnected)
throw new InvalidOperationException("Cannot configure lightning node without BTCPay connection");
if (!IsOnchainConfigured)
throw new InvalidOperationException("Cannot configure lightning node without on-chain wallet configuration");
if (IsOnchainLightningDerivationConfigured)
throw new InvalidOperationException("On-chain wallet is already configured with a lightning derivation");
await _onChainWalletManager.AddDerivation(WalletDerivation.LightningScripts, "Lightning", null);
// await _onChainWalletManager.AddDerivation(WalletDerivation.SpendableOutputs, "Lightning Spendables", null);
State = LightningNodeState.WaitingForConnection;
}
finally
{
_controlSemaphore.Release();
}
}
private async Task OnConnectionChanged(object? sender, (BTCPayConnectionState Old, BTCPayConnectionState New) valueTuple)
{
switch (IsHubConnected)
{
case true when State == LightningNodeState.WaitingForConnection:
State = LightningNodeState.Loading;
break;
case true when State is LightningNodeState.Loading or LightningNodeState.Loaded:
_ = StopNode();
break;
}
}
private async Task OnChainWalletManagerOnStateChanged(object? sender, (OnChainWalletState Old, OnChainWalletState New) e)
{
if (e.New == OnChainWalletState.Loaded)
{
State = LightningNodeState.Loading;
}
}
private async Task OnStateChanged(object? sender, (LightningNodeState Old, LightningNodeState New) state)
{
LightningNodeState? newState = null;
try
{
switch (state.New)
{
case LightningNodeState.WaitingForConnection:
{
if (IsHubConnected)
newState = LightningNodeState.Loading;
break;
}
case LightningNodeState.Loading:
if (!IsHubConnected)
{
newState = LightningNodeState.WaitingForConnection;
break;
}
if (!IsOnchainConfigured || !IsOnchainLightningDerivationConfigured)
{
newState = LightningNodeState.NotConfigured;
break;
}
await StartNode();
break;
case LightningNodeState.NotConfigured:
if (CanConfigureLightningNode)
{
await Generate();
}
break;
case LightningNodeState.Loaded:
await _controlSemaphore.WaitAsync();
_controlSemaphore.Release();
break;
// case LightningNodeState.Unloading:
// _nodeScope?.Dispose();
// State = _walletConfig is null
// ? LightningNodeState.NotConfigured
// : LightningNodeState.WaitingForConnection;
// break;
}
}
finally
{
if (newState is not null)
State = newState.Value;
}
}
protected override async Task ExecuteStartAsync(CancellationToken cancellationToken)
{
State = LightningNodeState.Init;
StateChanged += OnStateChanged;
_btcPayConnectionManager.ConnectionChanged += OnConnectionChanged;
_onChainWalletManager.StateChanged += OnChainWalletManagerOnStateChanged;
}
protected override async Task ExecuteStopAsync(CancellationToken cancellationToken)
{
_btcPayConnectionManager.ConnectionChanged -= OnConnectionChanged;
_onChainWalletManager.StateChanged += OnChainWalletManagerOnStateChanged;
StateChanged -= OnStateChanged;
_nodeScope?.Dispose();
}
}

View File

@ -1,8 +1,5 @@
using System.Text.Json.Serialization;
namespace BTCPayApp.Core.Attempt2;
namespace BTCPayApp.Core.Wallet;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum LightningNodeState
{
Init,

View File

@ -0,0 +1,512 @@
using BTCPayApp.CommonServer;
using BTCPayApp.Core.Contracts;
using BTCPayApp.Core.Data;
using BTCPayApp.Core.Helpers;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.Scripting;
using OutPoint = NBitcoin.OutPoint;
using TxOut = NBitcoin.TxOut;
namespace BTCPayApp.Core.Attempt2;
public class OnChainWalletManager : BaseHostedService
{
public const string PaymentMethodId = "BTC-CHAIN";
private readonly IConfigProvider _configProvider;
private readonly BTCPayAppServerClient _btcPayAppServerClient;
private readonly BTCPayConnectionManager _btcPayConnectionManager;
private readonly ILogger<OnChainWalletManager> _logger;
private readonly IMemoryCache _memoryCache;
private OnChainWalletState _state = OnChainWalletState.Init;
public WalletConfig? WalletConfig { get; private set; }
public Network? Network => WalletConfig is null ? null : Network.GetNetwork(WalletConfig.Network);
public OnChainWalletState State
{
get => _state;
private set
{
if (_state == value)
return;
var old = _state;
_state = value;
_logger.LogInformation("Wallet state changed: {State}", _state);
StateChanged?.Invoke(this, (old, value));
}
}
public event AsyncEventHandler<(OnChainWalletState Old, OnChainWalletState New)>? StateChanged;
public OnChainWalletManager(
IConfigProvider configProvider,
BTCPayAppServerClient btcPayAppServerClient,
BTCPayConnectionManager btcPayConnectionManager,
ILogger<OnChainWalletManager> logger,
IMemoryCache memoryCache)
{
_configProvider = configProvider;
_btcPayAppServerClient = btcPayAppServerClient;
_btcPayConnectionManager = btcPayConnectionManager;
_logger = logger;
_memoryCache = memoryCache;
}
protected override async Task ExecuteStartAsync(CancellationToken cancellationToken)
{
StateChanged += OnStateChanged;
_btcPayAppServerClient.OnNewBlock += OnNewBlock;
_btcPayAppServerClient.OnTransactionDetected += OnTransactionDetected;
_btcPayConnectionManager.ConnectionChanged += ConnectionChanged;
WalletConfig = await _configProvider.Get<WalletConfig>(WalletConfig.Key);
DetermineState();
if (IsHubConnected)
{
await Track();
_ = GetBestBlock();
State = OnChainWalletState.Loaded;
}
}
private bool IsHubConnected => _btcPayConnectionManager.ConnectionState is BTCPayConnectionState.ConnectedAsMaster;
public bool IsConfigured => WalletConfig is not null;
private async Task OnStateChanged(object? sender, (OnChainWalletState Old, OnChainWalletState New) e)
{
if (e is { Old: OnChainWalletState.NotConfigured or OnChainWalletState.WaitingForConnection } && IsHubConnected && !IsConfigured)
{
await Generate();
}
if (e is {New: OnChainWalletState.Loaded} && IsConfigured)
{
await Track();
}
if (e.New is OnChainWalletState.Loading)
{
DetermineState();
}
}
public async Task Generate()
{
await _controlSemaphore.WaitAsync();
try
{
if (State != OnChainWalletState.NotConfigured || IsConfigured || !IsHubConnected)
{
throw new InvalidOperationException("Cannot generate wallet in current state");
}
var mnemonic = new Mnemonic(Wordlist.English, WordCount.Twelve);
var mainnet = _btcPayConnectionManager.ReportedNetwork == Network.Main;
var path = new KeyPath($"m/84'/{(mainnet ? "0" : "1")}'/0'");
var fingerprint = mnemonic.DeriveExtKey().GetPublicKey().GetHDFingerPrint();
var xpub = mnemonic.DeriveExtKey().Derive(path).Neuter().ToString(_btcPayConnectionManager.ReportedNetwork);
var walletConfig = new WalletConfig
{
Mnemonic = mnemonic.ToString(),
Network = _btcPayConnectionManager.ReportedNetwork.ToString(),
Derivations = new Dictionary<string, WalletDerivation>()
{
[WalletDerivation.NativeSegwit] = new WalletDerivation()
{
Name = "Native Segwit",
Descriptor = OutputDescriptor.AddChecksum(
$"wpkh([{fingerprint.ToString()}/{path}]{xpub}/0/*)")
}
}
};
var result = await _btcPayConnectionManager.HubProxy.Pair(new PairRequest()
{
Derivations = walletConfig.Derivations.ToDictionary(pair => pair.Key, pair => pair.Value.Descriptor)
}).RunSync();
foreach (var keyValuePair in result)
{
walletConfig.Derivations[keyValuePair.Key].Identifier = keyValuePair.Value;
}
await _configProvider.Set(WalletConfig.Key, walletConfig, true);
WalletConfig = walletConfig;
State = OnChainWalletState.Loaded;
}
finally
{
_controlSemaphore.Release();
}
}
public async Task AddDerivation(string key, string name, string? descriptor)
{
await _controlSemaphore.WaitAsync();
try
{
if (State != OnChainWalletState.Loaded || !IsConfigured || !IsHubConnected)
{
throw new InvalidOperationException("Cannot add deriv in current state");
}
if (WalletConfig.Derivations.ContainsKey(key))
throw new InvalidOperationException("Derivation already exists");
var result = await _btcPayConnectionManager.HubProxy.Pair(new PairRequest
{
Derivations = new Dictionary<string, string?>()
{
[key] = descriptor
}
}).RunSync();
WalletConfig.Derivations[key] = new WalletDerivation()
{
Name = name,
Descriptor = descriptor,
Identifier = result[key]
};
await _configProvider.Set(WalletConfig.Key, WalletConfig, true);
}
finally
{
_controlSemaphore.Release();
}
}
private async Task ConnectionChanged(object? sender, (BTCPayConnectionState Old, BTCPayConnectionState New) valueTuple)
{
DetermineState();
}
private void DetermineState()
{
if (IsHubConnected && IsConfigured)
State = OnChainWalletState.Loaded;
else if (!IsHubConnected)
State = OnChainWalletState.WaitingForConnection;
else if (!IsConfigured)
State = OnChainWalletState.NotConfigured;
}
private async Task Track()
{
if (!IsConfigured || !IsHubConnected)
return;
var identifiers = WalletConfig.Derivations.Select(pair => pair.Value.Identifier).ToArray();
var response = await _btcPayConnectionManager.HubProxy.Handshake(new AppHandshake
{
Identifiers = identifiers
}).RunSync();
var missing =
WalletConfig.Derivations.Where(pair => !response.IdentifiersAcknowledged.Contains(pair.Value.Identifier));
if (missing.Any())
{
_logger.LogWarning("Some identifiers that we had asked for BtcPayServer to track were not confirmed as being listened to. Tracking will be incomplete and functionality will critically fail.");
}
}
protected override async Task ExecuteStopAsync(CancellationToken cancellationToken)
{
_btcPayAppServerClient.OnNewBlock -= OnNewBlock;
_btcPayAppServerClient.OnTransactionDetected -= OnTransactionDetected;
_btcPayConnectionManager.ConnectionChanged -= ConnectionChanged;
WalletConfig = null;
State = OnChainWalletState.Init;
}
private async Task OnTransactionDetected(object? sender, TransactionDetectedRequest transactionDetectedRequest)
{
}
private async Task OnNewBlock(object? sender, string e)
{
_memoryCache.Remove("bestblock");
_ = GetBestBlock();
}
public async Task<Script> DeriveScript(string derivation)
{
var identifier = WalletConfig?.Derivations[derivation].Identifier;
var addr = await _btcPayConnectionManager.HubProxy.DeriveScript(identifier).RunSync();
return Script.FromHex(addr);
}
public async Task<byte[]?> SignTransaction(byte[] psbtBytes)
{
var psbt = PSBT.Load(psbtBytes, Network);
psbt = await SignTransaction(psbt);
return psbt?.ToBytes();
}
public async Task<PSBT?> SignTransaction(PSBT psbt)
{
var identifiers = WalletConfig.Derivations.Select(derivation => derivation.Value.Identifier).ToArray();
var updated = await _btcPayConnectionManager.HubProxy.UpdatePsbt(identifiers, psbt.ToHex()).RunSync();
psbt = PSBT.Parse(updated, Network);
var rootKey =new Mnemonic(WalletConfig.Mnemonic).DeriveExtKey();
foreach (var deriv in WalletConfig.Derivations.Values.Where(derivation => derivation.Descriptor is not null))
{
var data = deriv.Descriptor.ExtractFromDescriptor(Network);
if(data is null)
continue;
var accKey = rootKey.Derive(data.Value.Item2);
psbt = psbt.SignAll(data.Value.Item1.AsHDScriptPubKey(data.Value.Item3), accKey);
if(psbt.TryFinalize(out _))
break;
}
return psbt;
}
private static ICoin ToCoin(CoinResponse response)
{
var outpoint = OutPoint.Parse(response.Outpoint);
var scriptPubKey = Script.FromHex(response.Script);
var amount = Money.Coins(response.Value);
return new Coin(outpoint, new TxOut(amount, scriptPubKey));
}
// // public class SpendableOutputDescriptorCoin : Coin,ISignableCoin
// {
// public SpendableOutputDescriptorCoin(OutPoint fromOutpoint, TxOut fromTxOut, SpendableOutputDescriptor descriptor) : base(fromOutpoint, fromTxOut)
// {
// Descriptor = descriptor;
// }
//
// public SpendableOutputDescriptor Descriptor { get;}
// public async Task<PSBT> Sign(PSBT psbt)
// {
//
// UtilMethods.
// UtilMethods.SpendableOutputDescriptor_create_spendable_outputs_psbt(new SpendableOutputDescriptor[]{Descriptor}, )
// Descriptor.create_spendable_outpcreate_spendable_outputs_psbtuts_psbt
// switch (Descriptor)
// {
// case SpendableOutputDescriptor.SpendableOutputDescriptor_DelayedPaymentOutput spendableOutputDescriptorDelayedPaymentOutput:
// spendableOutputDescriptorDelayedPaymentOutput.delayed_payment_output.
// break;
// case SpendableOutputDescriptor.SpendableOutputDescriptor_StaticOutput spendableOutputDescriptorStaticOutput:
// //ignore
// break;
// case SpendableOutputDescriptor.SpendableOutputDescriptor_StaticPaymentOutput spendableOutputDescriptorStaticPaymentOutput:
// spendableOutputDescriptorStaticPaymentOutput.static_payment_output.psb
// break;
// default:
// throw new ArgumentOutOfRangeException(nameof(Descriptor));
// }
// }
// }
public class CoinWithKey : Coin,ISignableCoin
{
public Key Key { get; }
public CoinWithKey(OutPoint fromOutpoint, TxOut fromTxOut, Key key) : base(fromOutpoint, fromTxOut)
{
Key = key;
}
public async Task<PSBT> Sign(PSBT psbt)
{
return psbt.SignWithKeys(Key);
}
}
public interface ISignableCoin : ICoin
{
Task<PSBT> Sign(PSBT psbt);
}
public async Task<TxResp[]> GetTransactions()
{
var identifiersWhichWeCanDeriveKeysFor = WalletConfig.Derivations.Values
.Where(derivation => derivation.Descriptor is not null).Select(derivation => derivation.Identifier).ToArray();
var res= await _btcPayConnectionManager.HubProxy.GetTransactions(identifiersWhichWeCanDeriveKeysFor).RunSync();
return res.SelectMany(pair => pair.Value).OrderByDescending(resp => resp.Timestamp).ToArray();
}
public async Task<IEnumerable<ICoin>> GetUTXOS()
{
var identifiers = WalletConfig.Derivations.Values.Select(derivation => derivation.Identifier).ToArray();
var utxos = await _btcPayConnectionManager.HubProxy.GetUTXOs(identifiers).RunSync();
var identifiersWhichWeCanDeriveKeysFor = WalletConfig.Derivations.Values
.Where(derivation => derivation.Descriptor is not null).Select(derivation => derivation.Identifier).ToArray();
var result = new List<ICoin>();
var utxosThatWeCanDeriveKeysFor = utxos.Where(utxo => identifiersWhichWeCanDeriveKeysFor.Contains(utxo.Identifier)).ToArray();
foreach (var coin in utxosThatWeCanDeriveKeysFor)
{
var derivation =
WalletConfig.Derivations.Values.First(derivation => derivation.Identifier == coin.Identifier);
var data = derivation.Descriptor.ExtractFromDescriptor(Network);
if (data is null)
continue;
var coinKeyPath = KeyPath.Parse(coin.Path);
var key = new Mnemonic(WalletConfig.Mnemonic).DeriveExtKey().Derive(data.Value.Item2.KeyPath)
.Derive(coinKeyPath).PrivateKey;
var c = ToCoin(coin);
result.Add(new CoinWithKey(c.Outpoint, c.TxOut, key));
}
// if (WalletConfig.Derivations.TryGetValue(WalletDerivation.SpendableOutputs, out var spendableOutputDerivation))
// {
//
// var spendableOutputUtxos = utxos.Where(response => response.Identifier == spendableOutputDerivation.Identifier).ToArray();
// await using var context = await _dbContextFactory.CreateDbContextAsync();
// var scipts = spendableOutputUtxos.Select(response => response.Script).Distinct();
// var spendableCoins = await context.SpendableCoins.Where(coin => scipts.Contains(coin.Script)).ToListAsync();
//
// foreach (var spendableOutputUtxo in spendableOutputUtxos)
// {
// var spendableCoin = spendableCoins.FirstOrDefault(coin => coin.Script == spendableOutputUtxo.Script);
// if (spendableCoin is null)
// continue;
// var coin = ToCoin(spendableOutputUtxo);
// var data = SpendableOutputDescriptor.read(spendableCoin.Data);
// if(data is Result_SpendableOutputDescriptorDecodeErrorZ.Result_SpendableOutputDescriptorDecodeErrorZ_OK ok)
// result.Add(new SpendableOutputDescriptorCoin(coin.Outpoint, coin.TxOut, ok.res));
// }
// }
return result;
}
public async Task<(NBitcoin.Transaction Tx, ICoin[] SpentCoins, NBitcoin.Script Change)> CreateTransaction(
List<TxOut> txOuts, FeeRate? feeRate, List<Coin> explicitIns = null)
{
var availableCoins = (await GetUTXOS()).ToList();
feeRate ??= await GetFeeRate(1);
//TODO: do not hardcode this constant
var changeScript = await DeriveScript(WalletDerivation.NativeSegwit);
var txBuilder = Network
.CreateTransactionBuilder()
.SetChange(changeScript)
.SendEstimatedFees(feeRate);
txBuilder = txOuts.Aggregate(txBuilder, (current, c) => current.Send(c.ScriptPubKey, c.Value));
txBuilder.SendAllRemainingToChange();
NBitcoin.Transaction? tx;
if (explicitIns?.Any() is true)
{
txBuilder.AddCoins(explicitIns.ToArray());
}
while (true)
{
try
{
tx = txBuilder.BuildTransaction(true);
return (tx, txBuilder.FindSpentCoins(tx), changeScript);
}
catch (NotEnoughFundsException e)
{
if (!availableCoins.Any())
throw;
var newCoin = availableCoins.First();
//TODO: switch to nuilding a psbt and signing with the ISignableCoin interface
if(newCoin is CoinWithKey newCoinWithKey)
{
txBuilder.AddCoins(newCoin);
txBuilder.AddKeys(newCoinWithKey.Key);
}
availableCoins.Remove(newCoin);
}
}
}
public async Task RemoveDerivation(params string[] key)
{
await _controlSemaphore.WaitAsync();
try
{
if (State != OnChainWalletState.Loaded || WalletConfig is null)
{
throw new InvalidOperationException("Cannot remove deriv in current state");
}
var updated = key.Aggregate(false, (current, k) => current || WalletConfig.Derivations.Remove(k));
if (updated)
await _configProvider.Set(WalletConfig.Key, WalletConfig, true);
}
finally
{
_controlSemaphore.Release();
}
}
public async Task<BestBlockResponse?> GetBestBlock()
{
var res = await _memoryCache.GetOrCreateAsync("bestblock", async entry =>
{
_logger.LogInformation("Getting best block");
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
try
{
return await _btcPayConnectionManager.HubProxy.GetBestBlock().RunSync();
}
catch(Exception e)
{
_logger.LogError(e, "Error getting best block");
return null;
}
finally
{
_logger.LogInformation("Got best block");
}
});
if (res is null)
{
_memoryCache.Remove("bestblock");
}
return res;
}
public async Task BroadcastTransaction(Transaction valueTx, CancellationToken cancellationToken = default)
{
await _btcPayConnectionManager.HubProxy.BroadcastTransaction(valueTx.ToHex()).RunSync();
}
public async Task<FeeRate> GetFeeRate(int blockTarget)
{
try
{
return await _memoryCache.GetOrCreateAsync($"feerate_{blockTarget}", async entry =>
{
_logger.LogInformation("Getting fee rate for block target {BlockTarget}", blockTarget);
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
try
{
return new FeeRate(await _btcPayConnectionManager.HubProxy.GetFeeRate(blockTarget).RunSync());
}
finally
{
_logger.LogInformation("Got fee rate for block target {BlockTarget}", blockTarget);
}
});
}
catch (Exception e)
{
_logger.LogError(e, "Error getting fee rate, using hardcoded 100");
return new FeeRate(100m);
}
}
}
public enum OnChainWalletState
{
Init,
NotConfigured,
WaitingForConnection,
Loading,
Loaded
}

View File

@ -0,0 +1,9 @@
namespace BTCPayApp.Core.Attempt2;
public enum SetupState
{
Undetermined,
Pending,
Completed,
Failed
}

View File

@ -2,7 +2,7 @@
using System.Text;
using Microsoft.AspNetCore.DataProtection;
namespace BTCPayApp.Core.Backup;
namespace BTCPayApp.Core.Attempt2;
public class SingleKeyDataProtector : IDataProtector
{
@ -11,7 +11,9 @@ public class SingleKeyDataProtector : IDataProtector
public SingleKeyDataProtector(byte[] key)
{
if (key.Length != 32) // AES-256 key size
{
throw new ArgumentException("Key length must be 32 bytes.");
}
_key = key;
}
@ -30,10 +32,14 @@ public class SingleKeyDataProtector : IDataProtector
aes.Key = _key;
aes.GenerateIV();
var iv = aes.IV;
var encrypted = aes.EncryptCbc(plaintext, iv);
byte[] iv = aes.IV;
byte[] encrypted = aes.EncryptCbc(plaintext, iv);
return iv.Concat(encrypted).ToArray();
byte[] result = new byte[iv.Length + encrypted.Length];
Buffer.BlockCopy(iv, 0, result, 0, iv.Length);
Buffer.BlockCopy(encrypted, 0, result, iv.Length, encrypted.Length);
return result;
}
public byte[] Unprotect(byte[] protectedData)
@ -41,12 +47,13 @@ public class SingleKeyDataProtector : IDataProtector
using var aes = Aes.Create();
aes.Key = _key;
if (protectedData.Length == 0)
return protectedData;
byte[] iv = new byte[16];
byte[] cipherText = new byte[protectedData.Length - iv.Length];
var iv = protectedData.Take(16).ToArray();
var cipherText = protectedData.Skip(16).ToArray();
Buffer.BlockCopy(protectedData, 0, iv, 0, iv.Length);
Buffer.BlockCopy(protectedData, iv.Length, cipherText, 0, cipherText.Length);
return aes.DecryptCbc(cipherText, iv);
}
}
}

View File

@ -0,0 +1,289 @@
using System.Net.Http.Headers;
using System.Text.Json;
using BTCPayApp.Core.Auth;
using BTCPayApp.Core.Contracts;
using BTCPayApp.Core.Data;
using BTCPayApp.VSS;
using Google.Protobuf;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin;
using VSSProto;
namespace BTCPayApp.Core.Attempt2;
public class SyncService
{
private readonly ILogger<SyncService> _logger;
private readonly IAccountManager _accountManager;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly ISecureConfigProvider _secureConfigProvider;
public SyncService(
ILogger<SyncService> logger,
ISecureConfigProvider secureConfigProvider,
IAccountManager accountManager,
IHttpClientFactory httpClientFactory,
IDbContextFactory<AppDbContext> dbContextFactory)
{
_logger = logger;
_accountManager = accountManager;
_httpClientFactory = httpClientFactory;
_dbContextFactory = dbContextFactory;
_secureConfigProvider = secureConfigProvider;
}
private async Task<IDataProtector> GetDataProtector()
{
var key = await _secureConfigProvider.GetOrSet("encryptionKey",
async () => Convert.ToHexString(RandomUtils.GetBytes(32)).ToLowerInvariant());
return new SingleKeyDataProtector(Convert.FromHexString(key));
}
private async Task<IVSSAPI> GetVSSAPI()
{
var account = _accountManager.GetAccount();
if (account is null)
throw new InvalidOperationException("Account not found");
var vssUri = new Uri(new Uri(account.BaseUri), "vss/");
var httpClient = _httpClientFactory.CreateClient("vss");
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", account.AccessToken);
var vssClient = new HttpVSSAPIClient(vssUri, httpClient);
var protector = await GetDataProtector();
return new VSSApiEncryptorClient(vssClient, protector);
}
private async Task<KeyValue[]> CreateLocalVersions(AppDbContext dbContext)
{
var settings = dbContext.Settings.Where(setting => setting.Backup).Select(setting => new KeyValue()
{
Key = setting.EntityKey,
Version = setting.Version
});
var channels = dbContext.LightningChannels.Select(channel => new KeyValue()
{
Key = channel.EntityKey,
Version = channel.Version
});
var payments = dbContext.LightningPayments.Select(payment => new KeyValue()
{
Key = payment.EntityKey,
Version = payment.Version
});
return await settings.Concat(channels).Concat(payments).ToArrayAsync();
}
public async Task SyncToLocal(CancellationToken cancellationToken = default)
{
var backupApi = await GetVSSAPI();
await using var db = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var localVersions = await CreateLocalVersions(db);
var remoteVersions = await backupApi.ListKeyVersionsAsync(new ListKeyVersionsRequest(), cancellationToken);
await db.Database.BeginTransactionAsync(cancellationToken);
try
{
var triggers = await db.Database
.SqlQuery<TriggerRecord>($"SELECT name, sql FROM sqlite_master WHERE type = 'trigger'")
.ToListAsync(cancellationToken: cancellationToken);
await db.Database.ExecuteSqlRawAsync(
string.Join("; ", triggers.Select(trigger => $"DROP TRIGGER IF EXISTS {trigger.name}")),
cancellationToken: cancellationToken);
// delete local versions that are not in remote
// delete local versions which are lower than remote
var toDelete = localVersions.Where(localVersion =>
remoteVersions.KeyVersions.All(remoteVersion => remoteVersion.Key != localVersion.Key)
|| remoteVersions.KeyVersions.All(remoteVersion =>
remoteVersion.Key == localVersion.Key && remoteVersion.Version > localVersion.Version)).ToArray();
var toUpsert = remoteVersions.KeyVersions.Where(remoteVersion => localVersions.All(localVersion =>
localVersion.Key != remoteVersion.Key || localVersion.Version < remoteVersion.Version));
foreach (var upsertItem in toUpsert)
{
if (upsertItem.Value is null)
{
var item = await backupApi.GetObjectAsync(new GetObjectRequest()
{
Key = upsertItem.Key,
}, cancellationToken);
upsertItem.MergeFrom(item.Value);
}
}
var settingsToDelete = toDelete.Where(key => key.Key.StartsWith("Setting_")).Select(key => key.Key);
var channelsToDelete = toDelete.Where(key => key.Key.StartsWith("Channel_")).Select(key => key.Key);
var paymentsToDelete = toDelete.Where(key => key.Key.StartsWith("Payment_")).Select(key => key.Key);
await db.Settings.Where(setting => settingsToDelete.Contains(setting.EntityKey))
.ExecuteDeleteAsync(cancellationToken: cancellationToken);
await db.LightningChannels.Where(channel => channelsToDelete.Contains(channel.EntityKey))
.ExecuteDeleteAsync(cancellationToken: cancellationToken);
await db.LightningPayments.Where(payment => paymentsToDelete.Contains(payment.EntityKey))
.ExecuteDeleteAsync(cancellationToken: cancellationToken);
// upsert the rest when needed
var settingsToUpsert = toUpsert.Where(key => key.Key.StartsWith("Setting_")).Select(setting => new Setting()
{
Key = setting.Key.Split('_')[1],
Value = setting.Value.ToByteArray(),
Version = setting.Version,
Backup = true
});
var channelsToUpsert = toUpsert.Where(key => key.Key.StartsWith("Channel_"))
.Select(value => JsonSerializer.Deserialize<Channel>(value.Value.ToStringUtf8())!);
var paymentsToUpsert = toUpsert.Where(key => key.Key.StartsWith("Payment_")).Select(value =>
JsonSerializer.Deserialize<AppLightningPayment>(value.Value.ToStringUtf8())!);
await db.Settings.UpsertRange(settingsToUpsert).On(setting => setting.EntityKey)
.RunAsync(cancellationToken);
await db.LightningChannels.UpsertRange(channelsToUpsert).On(channel => channel.EntityKey)
.RunAsync(cancellationToken);
await db.LightningPayments.UpsertRange(paymentsToUpsert).On(payment => payment.EntityKey)
.RunAsync(cancellationToken);
await db.Database.ExecuteSqlRawAsync(string.Join("; ", triggers.Select(record => record.sql)),
cancellationToken: cancellationToken);
await db.Database.CommitTransactionAsync(cancellationToken);
await db.SaveChangesAsync(cancellationToken);
}
catch (Exception e)
{
await db.Database.RollbackTransactionAsync(cancellationToken);
throw;
}
}
private async Task<KeyValue?> GetValue(AppDbContext dbContext, Outbox outbox)
{
switch (outbox.Entity)
{
case "Setting":
var setting = await dbContext.Settings.SingleOrDefaultAsync(setting1 =>
setting1.EntityKey == outbox.Key && setting1.Backup);
if (setting == null)
return null;
return new KeyValue()
{
Key = outbox.Key,
Value = ByteString.CopyFrom(setting.Value),
Version = setting.Version
};
case "Channel":
var channel = await dbContext.LightningChannels.Include(channel1 => channel1.Aliases)
.SingleOrDefaultAsync(channel1 => channel1.EntityKey == outbox.Key);
if (channel == null)
return null;
var val = JsonSerializer.SerializeToUtf8Bytes(channel);
return new KeyValue()
{
Key = outbox.Key,
Value = ByteString.CopyFrom(val),
Version = channel.Version
};
case "Payment":
var payment = await dbContext.LightningPayments.SingleOrDefaultAsync(lightningPayment =>
lightningPayment.EntityKey == outbox.Key);
if (payment == null)
return null;
var paymentBytes = JsonSerializer.SerializeToUtf8Bytes(payment);
return new KeyValue()
{
Key = outbox.Key,
Value = ByteString.CopyFrom(paymentBytes),
Version = payment.Version
};
default:
throw new ArgumentOutOfRangeException();
}
}
public async Task SyncToRemote(long deviceIdentifier, CancellationToken cancellationToken = default)
{
var backupAPi = await GetVSSAPI();
await using var db = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var putObjectRequest = new PutObjectRequest();
var outbox = await db.OutboxItems.GroupBy(outbox1 => new {outbox1.Key})
.ToListAsync(cancellationToken: cancellationToken);
foreach (var outboxItemSet in outbox)
{
var orderedEnumerable = outboxItemSet.OrderByDescending(outbox1 => outbox1.Version)
.ThenByDescending(outbox1 => outbox1.ActionType).ToArray();
foreach (var item in orderedEnumerable)
{
if (item.ActionType == OutboxAction.Delete)
{
putObjectRequest.DeleteItems.Add(new KeyValue()
{
Key = item.Key, Version = item.Version
});
}
else
{
var kv = await GetValue(db, item);
if (kv != null)
{
putObjectRequest.TransactionItems.Add(kv);
break;
}
}
}
db.OutboxItems.RemoveRange(orderedEnumerable);
// Process outbox item
}
putObjectRequest.GlobalVersion = deviceIdentifier;
await backupAPi.PutObjectAsync(putObjectRequest, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
}
private (Task syncTask, CancellationTokenSource cts, bool local)? _syncTask;
public async Task StartSync(bool local, long deviceIdentifier, CancellationToken cancellationToken = default)
{
if (_syncTask.HasValue && _syncTask.Value.local == local)
return;
if (_syncTask.HasValue)
{
await _syncTask.Value.cts.CancelAsync();
}
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_syncTask = (ContinuouslySync(deviceIdentifier,local, cts.Token), cts, local);
}
public async Task StopSync()
{
if (_syncTask.HasValue)
{
await _syncTask.Value.cts.CancelAsync();
_syncTask = null;
}
}
private async Task ContinuouslySync(long deviceIdentifier, bool local, CancellationToken cancellationToken = default)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
if (local)
await SyncToLocal(cancellationToken);
else
await SyncToRemote(deviceIdentifier, cancellationToken);
await Task.Delay(2000, cancellationToken);
}
catch (Exception e)
{
_logger.LogError(e, "Error while syncing to {Local}", local ? "local" : "remote");
}
}
}
}

View File

@ -1,14 +0,0 @@
namespace BTCPayApp.Core.Auth;
public class AppPolicies
{
public const string CanModifySettings = "btcpay.plugin.app.canmodifysettings";
public static IEnumerable<string> AllPolicies
{
get
{
yield return CanModifySettings;
}
}
}

View File

@ -1,7 +1,9 @@
using System.Security.Claims;
using BTCPayApp.CommonServer.Models;
using BTCPayApp.Core.AspNetRip;
using BTCPayApp.Core.Contracts;
using BTCPayApp.Core.Helpers;
using BTCPayApp.Core.Models;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
@ -11,33 +13,54 @@ using Microsoft.Extensions.Options;
namespace BTCPayApp.Core.Auth;
public class AuthStateProvider(
IHttpClientFactory clientFactory,
IAuthorizationService authService,
ISecureConfigProvider secureProvider,
ConfigProvider configProvider,
IOptionsMonitor<IdentityOptions> identityOptions)
: AuthenticationStateProvider, IAccountManager, IHostedService
public class AuthStateProvider : AuthenticationStateProvider, IAccountManager, IHostedService
{
private const string AccountKeyPrefix = "Account";
private const string CurrentAccountKey = "CurrentAccount";
private bool _isInitialized;
private bool _refreshUserInfo;
private string? _currentStoreId;
private CancellationTokenSource? _pingCts;
private BTCPayAccount? _account;
// TODO: Move _userInfo to state
private AppUserInfo? _userInfo;
private readonly SemaphoreSlim _semaphore = new(1, 1);
private readonly ClaimsPrincipal _unauthenticated = new(new ClaimsIdentity());
private readonly IOptionsMonitor<IdentityOptions> _identityOptions;
private readonly IAuthorizationService _authService;
private readonly IConfigProvider _config;
public BTCPayAccount? Account { get; private set; }
public AppUserInfo? UserInfo { get; private set; }
public AppUserStoreInfo? CurrentStore => string.IsNullOrEmpty(_currentStoreId) ? null : GetUserStore(_currentStoreId);
public AsyncEventHandler<AppUserStoreInfo?>? OnStoreChanged { get; set; }
public AsyncEventHandler<AppUserInfo?>? OnUserInfoChanged { get; set; }
public AsyncEventHandler<string>? OnEncryptionKeyChanged { get; set; }
public BTCPayAccount? GetAccount() => _account;
public AppUserInfo? GetUserInfo() => _userInfo;
public Task StartAsync(CancellationToken cancellationToken)
public AsyncEventHandler<BTCPayAccount?>? OnBeforeAccountChange { get; set; }
public AsyncEventHandler<BTCPayAccount?>? OnAfterAccountChange { get; set; }
public AsyncEventHandler<AppUserStoreInfo?>? OnBeforeStoreChange { get; set; }
public AsyncEventHandler<AppUserStoreInfo?>? OnAfterStoreChange { get; set; }
public AuthStateProvider(
IConfigProvider config,
IAuthorizationService authService,
IOptionsMonitor<IdentityOptions> identityOptions)
{
_config = config;
_authService = authService;
_identityOptions = identityOptions;
}
private CancellationTokenSource? _pingCts;
public async Task StartAsync(CancellationToken cancellationToken)
{
_pingCts = new CancellationTokenSource();
_ = PingOccasionally(_pingCts.Token);
return Task.CompletedTask;
}
private async Task PingOccasionally(CancellationToken pingCtsToken)
{
while (pingCtsToken.IsCancellationRequested is false)
{
await GetAuthenticationStateAsync();
await Task.Delay(TimeSpan.FromSeconds(5), pingCtsToken);
}
}
public Task StopAsync(CancellationToken cancellationToken)
@ -46,32 +69,15 @@ public class AuthStateProvider(
return Task.CompletedTask;
}
private async Task PingOccasionally(CancellationToken pingCtsToken)
public BTCPayAppClient GetClient(string? baseUri = null)
{
while (pingCtsToken.IsCancellationRequested is false)
{
await GetAuthenticationStateAsync();
await Task.Delay(TimeSpan.FromSeconds(5), pingCtsToken);
}
}
public BTCPayAppClient GetClient(string? baseUri = null, string? token = null)
{
if (string.IsNullOrEmpty(baseUri) && string.IsNullOrEmpty(Account?.BaseUri))
if (string.IsNullOrEmpty(baseUri) && string.IsNullOrEmpty(_account?.BaseUri))
throw new ArgumentException("No base URI present or provided.", nameof(baseUri));
token ??= Account?.ModeToken ?? Account?.OwnerToken;
return new BTCPayAppClient(baseUri ?? Account!.BaseUri, token, clientFactory.CreateClient());
}
public async Task<string?> GetEncryptionKey()
{
return await secureProvider.Get<string>("encryptionKey");
}
public async Task SetEncryptionKey(string value)
{
await secureProvider.Set("encryptionKey", value);
OnEncryptionKeyChanged?.Invoke(this, value);
var client = new BTCPayAppClient(baseUri ?? _account!.BaseUri);
if (string.IsNullOrEmpty(baseUri) && !string.IsNullOrEmpty(_account?.AccessToken) && !string.IsNullOrEmpty(_account.RefreshToken))
client.SetAccess(_account.AccessToken, _account.RefreshToken, _account.AccessExpiry.GetValueOrDefault());
client.AccessRefreshed += OnAccessRefresh;
return client;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
@ -83,56 +89,49 @@ public class AuthStateProvider(
await _semaphore.WaitAsync();
// initialize with persisted account
if (!_isInitialized && Account == null)
if (!_isInitialized && _account == null)
{
Account = await secureProvider.Get<BTCPayAccount>(BTCPayAccount.Key);
_currentStoreId = (await configProvider.Get<BTCPayAppConfig>(BTCPayAppConfig.Key))?.CurrentStoreId;
_account = await GetCurrentAccount();
_isInitialized = true;
}
var oldUserInfo = UserInfo;
var hasOwnerToken = !string.IsNullOrEmpty(Account?.OwnerToken);
var hasModeToken = !string.IsNullOrEmpty(Account?.ModeToken);
var needsRefresh = _refreshUserInfo || UserInfo == null;
if (needsRefresh && hasOwnerToken)
var oldUserInfo = _userInfo;
if (_userInfo == null && _account?.HasTokens is true)
{
var cts = new CancellationTokenSource(5000);
UserInfo = await GetClient().GetUserInfo(cts.Token);
_refreshUserInfo = false;
await FetchUserInfo(cts.Token);
}
if (Account != null && UserInfo != null)
if (_userInfo != null)
{
var claims = new List<Claim>
{
new(identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, UserInfo.UserId!),
new(identityOptions.CurrentValue.ClaimsIdentity.UserNameClaimType, UserInfo.Name ?? UserInfo.Email!),
new(identityOptions.CurrentValue.ClaimsIdentity.EmailClaimType, UserInfo.Email!)
new(_identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, _userInfo.UserId!),
new(_identityOptions.CurrentValue.ClaimsIdentity.UserNameClaimType, _userInfo.Name ?? _userInfo.Email!),
new(_identityOptions.CurrentValue.ClaimsIdentity.EmailClaimType, _userInfo.Email!)
};
if (UserInfo.Roles?.Any() is true)
claims.AddRange(UserInfo.Roles.Select(role =>
new Claim(identityOptions.CurrentValue.ClaimsIdentity.RoleClaimType, role)));
if (UserInfo.Stores?.Any() is true)
claims.AddRange(UserInfo.Stores.Select(store =>
new Claim(store.Id, string.Join(',', store.Permissions ?? []))));
if (hasOwnerToken && !hasModeToken)
claims.Add(new Claim(identityOptions.CurrentValue.ClaimsIdentity.RoleClaimType, "DeviceOwner"));
user = new ClaimsPrincipal(new ClaimsIdentity(claims, "Greenfield"));
if (_userInfo.Roles?.Any() is true)
claims.AddRange(_userInfo.Roles.Select(role =>
new Claim(_identityOptions.CurrentValue.ClaimsIdentity.RoleClaimType, role)));
if (_userInfo.Stores?.Any() is true)
claims.AddRange(_userInfo.Stores.Select(store =>
new Claim(store.Id, string.Join(',', store.Permissions))));
user = new ClaimsPrincipal(new ClaimsIdentity(claims, AuthenticationSchemes.GreenfieldBearer));
// update account user info
_account!.SetInfo(_userInfo.Email!, _userInfo.Name, _userInfo.ImageUrl);
await UpdateAccount(_account);
}
var res = new AuthenticationState(user);
if (AppUserInfo.Equals(oldUserInfo, UserInfo)) return res;
OnUserInfoChanged?.Invoke(this, UserInfo);
if (Account != null && UserInfo != null)
await UpdateAccount(Account);
if (AppUserInfo.Equals(oldUserInfo, _userInfo))
return res;
NotifyAuthenticationStateChanged(Task.FromResult(res));
return res;
}
catch
{
UserInfo = null;
return new AuthenticationState(user);
}
finally
@ -143,78 +142,78 @@ public class AuthStateProvider(
public async Task<bool> CheckAuthenticated(bool refreshUser = false)
{
if (refreshUser) _refreshUserInfo = true;
if (refreshUser) await FetchUserInfo();
await GetAuthenticationStateAsync();
return UserInfo != null;
return _userInfo != null;
}
public async Task<bool> IsAuthorized(string policy, object? resource = null)
{
var authState = await GetAuthenticationStateAsync();
var result = await authService.AuthorizeAsync(authState.User, resource, policy);
var result = await _authService.AuthorizeAsync(authState.User, resource, policy);
return result.Succeeded;
}
public async Task<FormResult> SetCurrentStoreId(string? storeId)
public async Task Logout()
{
if (!string.IsNullOrEmpty(storeId))
{
var store = GetUserStore(storeId);
if (store == null) return new FormResult(false, $"Store with ID '{storeId}' does not exist or belong to the user.");
if (store.Id != CurrentStore?.Id)
await SetCurrentStore(store);
}
else
{
await SetCurrentStore(null);
}
return new FormResult(true);
_userInfo = null;
_account!.ClearAccess();
await UpdateAccount(_account);
await SetCurrentAccount(null);
}
private async Task SetCurrentStore(AppUserStoreInfo? store)
public async Task<FormResult> SetCurrentStoreId(string storeId)
{
if (_currentStoreId == store?.Id) return;
var store = GetUserStore(storeId);
if (store == null) return new FormResult(false, $"Store with ID '{storeId}' does not exist or belong to the user.");
if (store != null)
store = await EnsureStorePos(store);
OnBeforeStoreChange?.Invoke(this, GetCurrentStore());
string? message = null;
_currentStoreId = store?.Id;
var appConfig = await configProvider.Get<BTCPayAppConfig>(BTCPayAppConfig.Key) ?? new BTCPayAppConfig();
appConfig.CurrentStoreId = _currentStoreId;
await configProvider.Set(BTCPayAppConfig.Key, appConfig, true);
OnStoreChanged?.Invoke(this, store);
}
public async Task<AppUserStoreInfo> EnsureStorePos(AppUserStoreInfo store, bool? forceCreate = false)
{
if (string.IsNullOrEmpty(store.PosAppId) || forceCreate is true)
// create associated POS app if there is none
if (string.IsNullOrEmpty(store.PosAppId))
{
try
{
var posConfig = new PointOfSaleAppRequest { AppName = store.Name, DefaultView = PosViewType.Light };
await GetClient().CreatePointOfSaleApp(store.Id, posConfig);
await CheckAuthenticated(true);
store = GetUserStore(store.Id)!;
var app = await GetClient().CreatePointOfSaleApp(store.Id, posConfig);
message = $"The Point of Sale called \"{app.AppName}\" has been created for use with the app.";
await FetchUserInfo();
}
catch (Exception ex)
catch (Exception e)
{
// ignored
return new FormResult(false, e.Message);
}
}
return store;
_account!.CurrentStoreId = storeId;
await UpdateAccount(_account);
OnAfterStoreChange?.Invoke(this, store);
return new FormResult(true, string.IsNullOrEmpty(message) ? null : [message]);
}
private AppUserStoreInfo? GetUserStore(string storeId)
public async Task UnsetCurrentStore()
{
return UserInfo?.Stores?.FirstOrDefault(store => store.Id == storeId);
_account!.CurrentStoreId = null;
await UpdateAccount(_account);
}
public AppUserStoreInfo? GetUserStore(string storeId)
{
return _userInfo?.Stores?.FirstOrDefault(store => store.Id == storeId);
}
public AppUserStoreInfo? GetCurrentStore()
{
var storeId = _account?.CurrentStoreId;
return string.IsNullOrEmpty(storeId) ? null : GetUserStore(storeId);
}
public async Task<FormResult<AcceptInviteResult>> AcceptInvite(string inviteUrl, CancellationToken? cancellation = default)
{
var urlParts = inviteUrl.Split(Constants.InviteSeparator);
var urlParts = inviteUrl.Split("/invite/");
var serverUrl = urlParts.First();
var pathParts = urlParts.Last().Split("/");
var payload = new AcceptInviteRequest
@ -225,8 +224,8 @@ public class AuthStateProvider(
try
{
var response = await GetClient(serverUrl).AcceptInvite(payload, cancellation.GetValueOrDefault());
var account = new BTCPayAccount(serverUrl, response.Email!);
await SetAccount(account);
var account = await GetAccount(serverUrl, response.Email);
await SetCurrentAccount(account);
var message = "Invitation accepted.";
if (response.EmailHasBeenConfirmed is true)
message += " Your email has been confirmed.";
@ -237,22 +236,9 @@ public class AuthStateProvider(
: " Please set your password.";
return new FormResult<AcceptInviteResult>(true, message, response);
}
catch (Exception)
{
return new FormResult<AcceptInviteResult>(false, "Invalid invitation.", null);
}
}
public async Task<FormResult<LoginInfoResult>> LoginInfo(string serverUrl, string email, CancellationToken? cancellation = default)
{
try
{
var response = await GetClient(serverUrl).LoginInfo(email, cancellation.GetValueOrDefault());
return new FormResult<LoginInfoResult>(true, string.Empty, response);
}
catch (Exception e)
{
return new FormResult<LoginInfoResult>(false, e.Message, null);
return new FormResult<AcceptInviteResult>(false, e.Message, null);
}
}
@ -266,10 +252,11 @@ public class AuthStateProvider(
};
try
{
var expiryOffset = DateTimeOffset.Now;
var response = await GetClient(serverUrl).Login(payload, cancellation.GetValueOrDefault());
if (string.IsNullOrEmpty(response.AccessToken)) throw new Exception("Did not obtain valid API token.");
var account = new BTCPayAccount(serverUrl, email, response.AccessToken);
await SetAccount(account);
var account = await GetAccount(serverUrl, email);
account.SetAccess(response.AccessToken, response.RefreshToken, response.ExpiresIn, expiryOffset);
await SetCurrentAccount(account);
return new FormResult(true);
}
catch (Exception e)
@ -278,36 +265,16 @@ public class AuthStateProvider(
}
}
public async Task<FormResult> LoginWithCode(string serverUrl, string? email, string code, CancellationToken? cancellation = default)
public async Task<FormResult> LoginWithCode(string serverUrl, string email, string code, CancellationToken? cancellation = default)
{
try
{
var expiryOffset = DateTimeOffset.Now;
var client = GetClient(serverUrl);
var response = await client.Login(code, cancellation.GetValueOrDefault());
if (string.IsNullOrEmpty(response.AccessToken)) throw new Exception("Did not obtain valid API token.");
if (string.IsNullOrEmpty(email))
{
var clientWithToken = GetClient(serverUrl, response.AccessToken);
var userInfo = await clientWithToken.GetUserInfo();
email = userInfo?.Email!;
}
var account = new BTCPayAccount(serverUrl, email, response.AccessToken);
await SetAccount(account);
return new FormResult(true);
}
catch (Exception e)
{
return new FormResult(false, e.Message);
}
}
public async Task<FormResult> AddAccountWithEncyptionKey(string serverUrl, string email, string key)
{
try
{
var account = new BTCPayAccount(serverUrl, email);
await SetAccount(account);
await SetEncryptionKey(key);
var account = await GetAccount(serverUrl, email);
account.SetAccess(response.AccessToken, response.RefreshToken, response.ExpiresIn, expiryOffset);
await SetCurrentAccount(account);
return new FormResult(true);
}
catch (Exception e)
@ -318,31 +285,31 @@ public class AuthStateProvider(
public async Task<FormResult> Register(string serverUrl, string email, string password, CancellationToken? cancellation = default)
{
var payload = new CreateApplicationUserRequest
var payload = new SignupRequest
{
Email = email,
Password = password
};
try
{
var expiryOffset = DateTimeOffset.Now;
var response = await GetClient(serverUrl).RegisterUser(payload, cancellation.GetValueOrDefault());
var account = new BTCPayAccount(serverUrl, email);
var message = "Account created.";
if (response.ContainsKey("accessToken"))
{
var access = response.ToObject<AuthenticationResponse>();
if (string.IsNullOrEmpty(access?.AccessToken)) throw new Exception("Did not obtain valid API token.");
account.OwnerToken = access.AccessToken;
var access = response.ToObject<AccessTokenResponse>();
account.SetAccess(access.AccessToken, access.RefreshToken, access.ExpiresIn, expiryOffset);
}
else
{
var signup = response.ToObject<ApplicationUserData>();
if (signup?.RequiresEmailConfirmation is true)
var signup = response.ToObject<SignupResult>();
if (signup.RequiresConfirmedEmail)
message += " Please confirm your email.";
if (signup?.RequiresApproval is true)
if (signup.RequiresUserApproval)
message += " The new account requires approval by an admin before you can log in.";
}
await SetAccount(account);
await SetCurrentAccount(account);
return new FormResult(true, message);
}
catch (Exception e)
@ -362,14 +329,7 @@ public class AuthStateProvider(
try
{
var isForgotStep = string.IsNullOrEmpty(payload.ResetCode) && string.IsNullOrEmpty(payload.NewPassword);
var response = await GetClient(serverUrl).ResetPassword(payload, cancellation.GetValueOrDefault());
if (response?.ContainsKey("accessToken") is true)
{
var access = response.ToObject<AuthenticationResponse>();
var account = new BTCPayAccount(serverUrl, email, access!.AccessToken);
await SetAccount(account);
}
await GetClient(serverUrl).ResetPassword(payload, cancellation.GetValueOrDefault());
return new FormResult(true, isForgotStep
? "You should have received an email with a password reset code."
: "Your password has been reset.");
@ -398,23 +358,32 @@ public class AuthStateProvider(
}
}
public async Task<FormResult> SwitchMode(string storeId, string mode, CancellationToken? cancellation = default)
public async Task<FormResult<ApplicationUserData>> ChangeAccountInfo(string email, string? name, string? imageUrl, CancellationToken? cancellation = default)
{
if (Account == null || !string.IsNullOrEmpty(Account.ModeToken))
return new FormResult(false, "Cannot switch mode in current state.");
var payload = new SwitchModeRequest
var payload = new UpdateApplicationUserRequest
{
StoreId = storeId,
Mode = mode
Email = email,
Name = name,
ImageUrl = imageUrl
};
try
{
var response = await GetClient().SwitchMode(payload, cancellation.GetValueOrDefault());
if (string.IsNullOrEmpty(response.AccessToken)) throw new Exception("Did not obtain valid API token.");
var userData = await GetClient().UpdateCurrentUser(payload, cancellation.GetValueOrDefault());
_account!.SetInfo(userData.Email!, userData.Name, userData.ImageUrl);
_userInfo!.SetInfo(userData.Email!, userData.Name, userData.ImageUrl);
return new FormResult<ApplicationUserData>(true, "Your account info has been changed.", userData);
}
catch (Exception e)
{
return new FormResult<ApplicationUserData>(false, e.Message, null);
}
}
Account.ModeToken = response.AccessToken;
await SetAccount(Account);
public async Task<FormResult> RefreshAccess(CancellationToken? cancellation = default)
{
try
{
await GetClient().RefreshAccess(_account!.RefreshToken, cancellation);
return new FormResult(true);
}
catch (Exception e)
@ -423,53 +392,66 @@ public class AuthStateProvider(
}
}
public async Task<FormResult> SwitchToOwner(string password, string? otp = null, CancellationToken? cancellation = default)
private async void OnAccessRefresh(object? sender, AccessTokenResult access)
{
if (Account == null || string.IsNullOrEmpty(Account.ModeToken) || string.IsNullOrEmpty(Account.OwnerToken))
return new FormResult(false, "Cannot switch user in current state.");
if (_account == null) return;
_account.SetAccess(access.AccessToken, access.RefreshToken, access.Expiry);
await UpdateAccount(_account);
}
var payload = new LoginRequest
private static string GetKey(string accountId) => $"{AccountKeyPrefix}:{accountId}";
public async Task<IEnumerable<BTCPayAccount>> GetAccounts(string? hostFilter = null)
{
var prefix = $"{AccountKeyPrefix}:" + (hostFilter == null ? "" : $"{hostFilter}:");
var keys = (await _config.List(prefix)).ToArray();
var accounts = new List<BTCPayAccount>();
foreach (var key in keys)
{
Email = Account.Email,
Password = password,
TwoFactorCode = otp
};
try
{
var response = await GetClient().Login(payload, cancellation.GetValueOrDefault());
if (string.IsNullOrEmpty(response.AccessToken)) throw new Exception("Did not obtain valid API token.");
Account.ModeToken = null;
await SetAccount(Account);
return new FormResult(true);
}
catch (Exception e)
{
return new FormResult(false, e.Message);
var account = await _config.Get<BTCPayAccount>(key);
accounts.Add(account!);
}
return accounts;
}
public async Task Logout()
public async Task UpdateAccount(BTCPayAccount account)
{
if (Account == null) return;
Account.OwnerToken = Account.ModeToken = null;
await SetAccount(Account);
await _config.Set(GetKey(account.Id), account, false);
}
private async Task UpdateAccount(BTCPayAccount account)
public async Task RemoveAccount(BTCPayAccount account)
{
await secureProvider.Set(BTCPayAccount.Key, account);
await _config.Set<BTCPayAccount>(GetKey(account.Id), null, false);
}
private async Task SetAccount(BTCPayAccount account)
private async Task<BTCPayAccount> GetAccount(string serverUrl, string email)
{
var storeId = CurrentStore?.Id;
var accountId = BTCPayAccount.GetId(serverUrl, email);
var account = await _config.Get<BTCPayAccount>(GetKey(accountId));
return account ?? new BTCPayAccount(serverUrl, email);
}
await UpdateAccount(account);
Account = account;
UserInfo = null;
private async Task<BTCPayAccount?> GetCurrentAccount()
{
var accountId = await _config.Get<string>(CurrentAccountKey);
if (string.IsNullOrEmpty(accountId)) return null;
return await _config.Get<BTCPayAccount>(GetKey(accountId));
}
private async Task SetCurrentAccount(BTCPayAccount? account)
{
OnBeforeAccountChange?.Invoke(this, _account);
if (account != null) await UpdateAccount(account);
await _config.Set(CurrentAccountKey, account?.Id, false);
_account = account;
_userInfo = null;
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
OnAfterAccountChange?.Invoke(this, _account);
}
if (!string.IsNullOrEmpty(storeId)) await SetCurrentStoreId(storeId);
private async Task FetchUserInfo(CancellationToken cancellationToken = default)
{
_userInfo = await GetClient().GetUserInfo(cancellationToken);
}
}

View File

@ -1,76 +0,0 @@
using BTCPayApp.Core.Helpers;
using BTCPayServer.Client;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace BTCPayApp.Core.Auth;
public class AuthorizationHandler(IOptionsMonitor<IdentityOptions> identityOptions) : AuthorizationHandler<PolicyRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, PolicyRequirement requirement)
{
if (context.User.Identity?.AuthenticationType != "Greenfield")
return Task.CompletedTask;
var userId = context.User.Claims.FirstOrDefault(c => c.Type == identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType)?.Value;
if (string.IsNullOrEmpty(userId))
return Task.CompletedTask;
var permissionSet = new PermissionSet();
var success = false;
var isAdmin = context.User.IsInRole("ServerAdmin");
var isOwner = context.User.IsInRole("DeviceOwner");
var storeId = context.Resource as string;
var policy = requirement.Policy;
var requiredUnscoped = false;
if (policy.EndsWith(':'))
{
policy = policy[..^1];
requiredUnscoped = true;
storeId = null;
}
if (!string.IsNullOrEmpty(storeId))
{
var permissions = context.User.Claims.FirstOrDefault(c => c.Type == storeId)?.Value;
if (!string.IsNullOrEmpty(permissions))
{
permissionSet = new PermissionSet(permissions.Split(',')
.Select(s => Permission.TryParse(s, out var permission) ? permission : null)
.Where(s => s != null).ToArray());
}
}
if (Policies.IsServerPolicy(policy) && isAdmin)
{
success = true;
}
else if (Policies.IsUserPolicy(policy) && !string.IsNullOrEmpty(userId))
{
success = true;
}
else if (Policies.IsStorePolicy(policy) && !string.IsNullOrEmpty(storeId))
{
if (!success && permissionSet.Contains(policy, storeId))
{
success = true;
}
if (!success && requiredUnscoped && string.IsNullOrEmpty(storeId))
{
success = true;
}
}
else if (Policies.IsPluginPolicy(policy) && policy.StartsWith("btcpay.plugin.app"))
{
success = isOwner;
}
if (success)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}

View File

@ -1,4 +1,4 @@
namespace BTCPayApp.Core.Models;
namespace BTCPayApp.Core.Auth;
public class FormResult(bool succeeded, string[]? messages = null)
{

View File

@ -1,33 +1,34 @@
using BTCPayApp.CommonServer.Models;
using BTCPayApp.Core.Helpers;
using BTCPayApp.Core.Models;
using BTCPayServer.Client.Models;
namespace BTCPayApp.Core.Auth;
public interface IAccountManager
{
public BTCPayAccount? Account { get; }
public AppUserInfo? UserInfo { get; }
public AppUserStoreInfo? CurrentStore { get; }
public BTCPayAppClient GetClient(string? baseUri = null, string? token = null);
public Task<string?> GetEncryptionKey();
public Task SetEncryptionKey(string value);
public BTCPayAccount? GetAccount();
public Task<IEnumerable<BTCPayAccount>> GetAccounts(string? hostFilter = null);
public AppUserInfo? GetUserInfo();
public BTCPayAppClient GetClient(string? baseUri = null);
public Task<bool> CheckAuthenticated(bool refreshUser = false);
public Task<bool> IsAuthorized(string policy, object? resource = null);
public Task<FormResult> AddAccountWithEncyptionKey(string serverUrl, string email, string key);
public Task<FormResult<AcceptInviteResult>> AcceptInvite(string inviteUrl, CancellationToken? cancellation = default);
public Task<FormResult<LoginInfoResult>> LoginInfo(string serverUrl, string email, CancellationToken? cancellation = default);
public Task<FormResult> Login(string serverUrl, string email, string password, string? otp, CancellationToken? cancellation = default);
public Task<FormResult> LoginWithCode(string serverUrl, string? email, string code, CancellationToken? cancellation = default);
public Task<FormResult> LoginWithCode(string serverUrl, string email, string code, CancellationToken? cancellation = default);
public Task<FormResult> Register(string serverUrl, string email, string password, CancellationToken? cancellation = default);
public Task<FormResult> ResetPassword(string serverUrl, string email, string? resetCode, string? newPassword, CancellationToken? cancellation = default);
public Task<FormResult<ApplicationUserData>> ChangePassword(string currentPassword, string newPassword, CancellationToken? cancellation = default);
public Task<FormResult> SwitchMode(string storeId, string mode, CancellationToken? cancellation = default);
public Task<FormResult> SwitchToOwner(string password, string? otp, CancellationToken? cancellation = default);
public Task<FormResult> SetCurrentStoreId(string? storeId);
public Task<AppUserStoreInfo> EnsureStorePos(AppUserStoreInfo store, bool? forceCreate = false);
public Task<FormResult<ApplicationUserData>> ChangeAccountInfo(string email, string? name, string? imageUrl, CancellationToken? cancellation = default);
public Task<FormResult> RefreshAccess(CancellationToken? cancellation = default);
public Task<FormResult> SetCurrentStoreId(string storeId);
public Task UnsetCurrentStore();
public AppUserStoreInfo? GetCurrentStore();
public AppUserStoreInfo? GetUserStore(string storeId);
public Task Logout();
public AsyncEventHandler<AppUserInfo?>? OnUserInfoChanged { get; set; }
public AsyncEventHandler<AppUserStoreInfo?>? OnStoreChanged { get; set; }
public AsyncEventHandler<string>? OnEncryptionKeyChanged { get; set; }
public Task UpdateAccount(BTCPayAccount account);
public Task RemoveAccount(BTCPayAccount account);
public AsyncEventHandler<BTCPayAccount?>? OnBeforeAccountChange { get; set; }
public AsyncEventHandler<BTCPayAccount?>? OnAfterAccountChange { get; set; }
public AsyncEventHandler<AppUserStoreInfo?>? OnBeforeStoreChange { get; set; }
public AsyncEventHandler<AppUserStoreInfo?>? OnAfterStoreChange { get; set; }
}

View File

@ -0,0 +1,11 @@
namespace BTCPayApp.Core.Auth;
public class Invoice
{
public string? Id { get; set; }
public string? OrderId { get; set; }
public string? Status { get; set; }
public DateTimeOffset Date { get; set; }
public string? Currency { get; set; }
public decimal Amount { get; set; }
}

View File

@ -0,0 +1,10 @@
namespace BTCPayApp.Core.Auth;
public class Notification
{
public string? Id { get; set; }
public string? Type { get; set; }
public DateTimeOffset Created { get; set; }
public string? Body { get; set; }
public bool Seen { get; set; }
}

View File

@ -1,13 +1,48 @@
using Newtonsoft.Json;
namespace BTCPayApp.Core;
public class BTCPayAccount(string baseUri, string email, string? ownerToken = null)
public class BTCPayAccount(string baseUri, string email)
{
public const string Key = "account";
public string Id { get; private set; } = $"{new Uri(baseUri).Host}:{email}";
public static string GetId(string baseUri, string email) => $"{new Uri(baseUri).Host}:{email}";
public readonly string Id = GetId(baseUri, email);
public string BaseUri { get; private set; } = WithTrailingSlash(baseUri);
public string Email { get; private set; } = email;
public string? OwnerToken { get; set; } = ownerToken;
public string? ModeToken { get; set; }
public string? Name { get; set; }
public string? ImageUrl { get; set; }
public string? AccessToken { get; set; }
public string? RefreshToken { get; set; }
public DateTimeOffset? AccessExpiry { get; set; }
public string? CurrentStoreId { get; set; }
public void SetAccess(string accessToken, string refreshToken, long expiresInSeconds, DateTimeOffset? expiryOffset = null)
{
var expiry = (expiryOffset ?? DateTimeOffset.Now) + TimeSpan.FromSeconds(expiresInSeconds);
SetAccess(accessToken, refreshToken, expiry);
}
public void SetAccess(string accessToken, string refreshToken, DateTimeOffset expiry)
{
AccessToken = accessToken;
RefreshToken = refreshToken;
AccessExpiry = expiry;
}
public void ClearAccess()
{
AccessToken = RefreshToken = null;
AccessExpiry = null;
}
public void SetInfo(string email, string? name, string? imageUrl)
{
Email = email;
Name = name;
ImageUrl = imageUrl;
}
[JsonIgnore]
public bool HasTokens => !string.IsNullOrEmpty(AccessToken) && !string.IsNullOrEmpty(RefreshToken);
private static string WithTrailingSlash(string s)
{

View File

@ -1,43 +1,56 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AsyncKeyedLock" Version="7.0.0" />
<PackageReference Include="FlexLabs.EntityFrameworkCore.Upsert" Version="8.0.0" />
<PackageReference Include="Laraue.EfCoreTriggers.SqlLite" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="NBitcoin" Version="7.0.37" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="8.0.6" />
<PackageReference Include="org.ldk" Version="0.0.123" />
<PackageReference Include="TypedSignalR.Client" Version="3.5.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BTCPayApp.VSS\BTCPayApp.VSS.csproj" />
<ProjectReference Include="..\submodules\btcpayserver\BTCPayApp.CommonServer\BTCPayApp.CommonServer.csproj" />
<ProjectReference Include="..\submodules\btcpayserver\BTCPayServer.Abstractions\BTCPayServer.Abstractions.csproj" />
<ProjectReference Include="..\submodules\btcpayserver\BTCPayServer.Client\BTCPayServer.Client.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\submodules\btcpayserver\BTCPayServer.Client\BTCPayServer.Client.csproj"/>
<PackageReference Include="AsyncKeyedLock" Version="7.1.4"/>
<PackageReference Include="FlexLabs.EntityFrameworkCore.Upsert" Version="8.1.0"/>
<PackageReference Include="Laraue.EfCoreTriggers.SqlLite" Version="8.1.2"/>
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="8.0.15" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.15" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="8.0.15" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="NBitcoin" Version="8.0.13" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="8.0.11" />
<PackageReference Include="org.ldk" Version="0.1.2" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.SQLite.Maui" Version="1.9.7" />
<PackageReference Include="TypedSignalR.Client" Version="3.6.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.DataProtection.Abstractions" Version="8.0.15" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.15" />
<PackageReference Include="VSS" Version="1.0.1" />
</ItemGroup>
</Project>

View File

@ -1,22 +1,129 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using BTCPayApp.Core.Models;
using System.Web;
using BTCPayApp.CommonServer.Models;
using BTCPayApp.Core.AspNetRip;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using AccessTokenResponse = BTCPayApp.Core.AspNetRip.AccessTokenResponse;
using ProblemDetails = BTCPayApp.Core.AspNetRip.ProblemDetails;
using RefreshRequest = BTCPayApp.Core.AspNetRip.RefreshRequest;
namespace BTCPayApp.Core;
public class BTCPayAppClient(string baseUri, string? apiKey = null, HttpClient? client = null) : BTCPayServerClient(new Uri(baseUri), apiKey, client)
public class BTCPayAppClient(string baseUri) : BTCPayServerClient(new Uri(baseUri))
{
private const string RefreshPath = "btcpayapp/refresh";
private DateTimeOffset? AccessExpiry { get; set; } // TODO: Incorporate in refresh check
private string? AccessToken { get; set; }
private string? RefreshToken { get; set; }
public event EventHandler<AccessTokenResult>? AccessRefreshed;
public void SetAccess(string accessToken, string refreshToken, DateTimeOffset expiry)
{
AccessToken = accessToken;
RefreshToken = refreshToken;
AccessExpiry = expiry;
}
private void ClearAccess()
{
AccessToken = RefreshToken = null;
AccessExpiry = null;
}
protected override HttpRequestMessage CreateHttpRequest(string path, Dictionary<string, object>? queryPayload = null, HttpMethod? method = null)
{
var req = base.CreateHttpRequest(path, queryPayload, method);
req.Headers.Add("User-Agent", "BTCPayAppClient");
req.Headers.Add("Accept", "application/json");
if (!string.IsNullOrEmpty(AccessToken))
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", AccessToken);
return req;
}
protected override async Task<T> HandleResponse<T>(HttpResponseMessage res)
{
if (res is { IsSuccessStatusCode: false })
{
var req = res.RequestMessage;
if (res.StatusCode == HttpStatusCode.Unauthorized && !string.IsNullOrEmpty(RefreshToken))
{
// try refresh and recurse if the token could be renewed
var uri = req!.RequestUri;
var path = uri!.AbsolutePath;
if (!path.EndsWith(RefreshPath))
{
var (refresh, _) = await RefreshAccess(RefreshToken);
if (refresh != null)
{
if (req.Content is not null)
{
var content = await req.Content.ReadAsStringAsync();
var payload = JsonConvert.DeserializeObject<T>(content);
return await SendHttpRequest<T>(path, bodyPayload: payload, method: req.Method);
}
var query = HttpUtility.ParseQueryString(uri.Query);
var queryPayload = query.HasKeys() ? query.AllKeys.ToDictionary(k => k, k => query[k]) : null;
return await SendHttpRequest<T>(path, queryPayload, method: req.Method);
}
}
ClearAccess();
}
else
{
// try parsing as ProblemDetails
try
{
var content = await res.Content.ReadAsStringAsync();
var err = JsonConvert.DeserializeObject<ProblemDetails>(content);
if (err?.Status != null && !string.IsNullOrEmpty(err.Detail))
{
var error = new GreenfieldAPIError("unauthorized", err.Detail);
throw new GreenfieldAPIException(err.Status.Value, error);
}
}
catch (JsonSerializationException e)
{
// ignored
}
}
}
return await base.HandleResponse<T>(res);
}
private AccessTokenResult HandleAccessTokenResponse(AccessTokenResponse response, DateTimeOffset expiryOffset)
{
var expiry = expiryOffset + TimeSpan.FromSeconds(response.ExpiresIn);
SetAccess(response.AccessToken, response.RefreshToken, expiry);
return new AccessTokenResult(response.AccessToken, response.RefreshToken, expiry);
}
public async Task<(AccessTokenResult? success, string? errorCode)> RefreshAccess(string? refreshToken = null, CancellationToken? cancellation = default)
{
var token = refreshToken ?? RefreshToken;
if (string.IsNullOrEmpty(token))
throw new ArgumentException("No refresh token present or provided.", nameof(refreshToken));
var payload = new RefreshRequest { RefreshToken = token };
var now = DateTimeOffset.Now;
try
{
var tokenResponse = await SendHttpRequest<AccessTokenResponse>(RefreshPath, bodyPayload: payload, method: HttpMethod.Post);
var res = HandleAccessTokenResponse(tokenResponse, now);
AccessRefreshed?.Invoke(this, res);
return (res, null);
}
catch (Exception e)
{
return (null, e.Message);
}
}
public async Task<AppInstanceInfo?> GetInstanceInfo(CancellationToken cancellation = default)
{
return await SendHttpRequest<AppInstanceInfo>("btcpayapp/instance", null, HttpMethod.Get, cancellation);
@ -32,66 +139,30 @@ public class BTCPayAppClient(string baseUri, string? apiKey = null, HttpClient?
return await SendHttpRequest<CreateStoreData>("btcpayapp/create-store", null, HttpMethod.Get, cancellation);
}
public async Task<JObject> RegisterUser(CreateApplicationUserRequest payload, CancellationToken cancellation = default)
public async Task<JObject> RegisterUser(SignupRequest payload, CancellationToken cancellation)
{
return await SendHttpRequest<JObject>("btcpayapp/register", payload, HttpMethod.Post, cancellation);
}
public async Task<LoginInfoResult> LoginInfo(string email, CancellationToken cancellation = default)
public async Task<AccessTokenResponse> Login(LoginRequest payload, CancellationToken cancellation)
{
var payload = new Dictionary<string, object> { { "email", email } };
return await SendHttpRequest<LoginInfoResult>("btcpayapp/login-info", payload, HttpMethod.Get, cancellation);
return await SendHttpRequest<AccessTokenResponse>("btcpayapp/login", payload, HttpMethod.Post, cancellation);
}
public async Task<AuthenticationResponse> Login(LoginRequest payload, CancellationToken cancellation = default)
public async Task<AccessTokenResponse> Login(string loginCode, CancellationToken cancellation)
{
return await SendHttpRequest<AuthenticationResponse>("btcpayapp/login", payload, HttpMethod.Post, cancellation);
return await SendHttpRequest<AccessTokenResponse>("btcpayapp/login/code", loginCode, HttpMethod.Post, cancellation);
}
public async Task<AuthenticationResponse> SwitchMode(SwitchModeRequest payload, CancellationToken cancellation = default)
{
return await SendHttpRequest<AuthenticationResponse>("btcpayapp/switch-mode", payload, HttpMethod.Post, cancellation);
}
public async Task<AuthenticationResponse> Login(string loginCode, CancellationToken cancellation = default)
{
return await SendHttpRequest<AuthenticationResponse>("btcpayapp/login/code", loginCode, HttpMethod.Post, cancellation);
}
public async Task<AcceptInviteResult> AcceptInvite(AcceptInviteRequest payload, CancellationToken cancellation = default)
public async Task<AcceptInviteResult> AcceptInvite(AcceptInviteRequest payload, CancellationToken cancellation)
{
return await SendHttpRequest<AcceptInviteResult>("btcpayapp/accept-invite", payload, HttpMethod.Post, cancellation);
}
public async Task<JObject?> ResetPassword(ResetPasswordRequest payload, CancellationToken cancellation = default)
public async Task ResetPassword(ResetPasswordRequest payload, CancellationToken cancellation)
{
var isForgotStep = string.IsNullOrEmpty(payload.ResetCode) && string.IsNullOrEmpty(payload.NewPassword);
var path = isForgotStep ? "btcpayapp/forgot-password" : "btcpayapp/reset-password";
return await SendHttpRequest<JObject?>(path, payload, HttpMethod.Post, cancellation);
}
public async Task<JObject?> CreatePosInvoice(Models.CreatePosInvoiceRequest req, CancellationToken cancellation = default)
{
var query = new Dictionary<string, object>();
if (req.DiscountPercent != null) query.Add("discount", req.DiscountPercent.Value.ToString(CultureInfo.InvariantCulture));
if (req.Tip != null) query.Add("tip", req.Tip.Value.ToString(CultureInfo.InvariantCulture));
if (req.PosData != null) query.Add("posData", req.PosData);
return await SendHttpRequest<JObject?>($"apps/{req.AppId}/pos/light", query, HttpMethod.Post, cancellation);
}
public async Task<string> SubmitLNURLWithdrawForInvoice(SubmitLnUrlRequest req, CancellationToken cancellation = default)
{
return await SendHttpRequest<string>($"plugins/NFC", req, HttpMethod.Post, cancellation);
}
public virtual async Task<T> UploadFileRequest<T>(string apiPath, StreamContent fileContent, string fileName, string mimeType, CancellationToken token = default)
{
using MultipartFormDataContent multipartContent = new();
fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse(mimeType);
multipartContent.Add(fileContent, "file", fileName);
var req = CreateHttpRequest(apiPath, null, HttpMethod.Post);
req.Content = multipartContent;
using var resp = await _httpClient.SendAsync(req, token);
return await HandleResponse<T>(resp);
await SendHttpRequest<EmptyResult>(path, payload, HttpMethod.Post, cancellation);
}
}

View File

@ -2,9 +2,6 @@ namespace BTCPayApp.Core;
public class BTCPayAppConfig
{
public const string Key = "appconfig";
public bool RecoveryPhraseVerified { get; set; }
public bool UseBiometricAuth { get; set; }
public const string Key = "AppConfig";
public string? Passcode { get; set; }
public string? CurrentStoreId { get; set; }
}

View File

@ -1,59 +0,0 @@
using BTCPayApp.Core.Data;
using BTCPayApp.Core.LDK;
using BTCPayServer.Lightning;
namespace BTCPayApp.Core.BTCPayServer;
public static class AppToServerHelper
{
public static LightningInvoice ToInvoice(this AppLightningPayment lightningPayment)
{
return new LightningInvoice
{
Id = lightningPayment.PaymentHash?.ToString(),
Amount = lightningPayment.Value,
PaymentHash = lightningPayment.PaymentHash?.ToString(),
Preimage = lightningPayment.Preimage,
ExpiresAt = lightningPayment.AdditionalData[PaymentsManager.LightningPaymentExpiryKey].GetDateTimeOffset(),
PaidAt = lightningPayment.Status == LightningPaymentStatus.Complete
? DateTimeOffset.UtcNow
: null, //TODO: store these in ln payment
BOLT11 = lightningPayment.PaymentRequest?.ToString(),
Status = lightningPayment.Status == LightningPaymentStatus.Complete
? LightningInvoiceStatus.Paid
: lightningPayment.PaymentRequest?.ExpiryDate < DateTimeOffset.UtcNow
? LightningInvoiceStatus.Expired
: LightningInvoiceStatus.Unpaid,
AmountReceived = lightningPayment.Status == LightningPaymentStatus.Complete? lightningPayment.Value: null
};
}
public static LightningPayment ToPayment(this AppLightningPayment lightningPayment)
{
return new LightningPayment
{
Id = lightningPayment.PaymentHash?.ToString(),
Amount = LightMoney.MilliSatoshis(lightningPayment.Value),
PaymentHash = lightningPayment.PaymentHash?.ToString(),
Preimage = lightningPayment.Preimage,
BOLT11 = lightningPayment.PaymentRequest?.ToString(),
Status = lightningPayment.Status,
Fee = lightningPayment.AdditionalData.TryGetValue("feePaid", out var feePaid) ? LightMoney.MilliSatoshis((long)feePaid.GetInt64()) : null,
CreatedAt = lightningPayment.Timestamp
};
}
public static async Task<List<LightningPayment>> ToPayments(this Task<List<AppLightningPayment>> appLightningPayments)
{
var result = await appLightningPayments;
return result.Select(ToPayment).ToList();
}
public static async Task<List<LightningInvoice>> ToInvoices(this Task<List<AppLightningPayment>> appLightningPayments)
{
var result = await appLightningPayments;
return result.Select(ToInvoice).ToList();
}
}

View File

@ -1,237 +0,0 @@
using System.Text;
using BTCPayApp.Core.Helpers;
using BTCPayApp.Core.LDK;
using BTCPayApp.Core.Wallet;
using BTCPayServer.Client.Models;
using BTCPayServer.Lightning;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.Crypto;
using org.ldk.structs;
namespace BTCPayApp.Core.BTCPayServer;
public class BTCPayAppServerClient(ILogger<BTCPayAppServerClient> _logger, IServiceProvider _serviceProvider)
: IBTCPayAppHubClient
{
public event AsyncEventHandler<string>? OnNewBlock;
public event AsyncEventHandler<TransactionDetectedRequest>? OnTransactionDetected;
public event AsyncEventHandler<string>? OnNotifyNetwork;
public event AsyncEventHandler<string>? OnServerNodeInfo;
public event AsyncEventHandler<long?>? OnMasterUpdated;
public event AsyncEventHandler<ServerEvent>? OnNotifyServerEvent;
private LDKNode? Node => _serviceProvider.GetRequiredService<LightningNodeManager>().Node;
private PaymentsManager? PaymentsManager => Node?.PaymentsManager;
private LightningAPIKeyManager? ApiKeyManager => Node?.ApiKeyManager;
public async Task NotifyServerEvent(ServerEvent ev)
{
_logger.LogInformation("NotifyServerEvent: {Event}", ev.ToString());
if (OnNotifyServerEvent is null) return;
await OnNotifyServerEvent.Invoke(this, ev);
}
public async Task NotifyNetwork(string network)
{
_logger.LogInformation("NotifyNetwork: {Network}", network);
if (OnNotifyNetwork is null) return;
await OnNotifyNetwork.Invoke(this, network);
}
public async Task NotifyServerNode(string nodeInfo)
{
_logger.LogInformation("NotifyServerNode: {NodeInfo}", nodeInfo);
if (OnServerNodeInfo is null) return;
await OnServerNodeInfo.Invoke(this, nodeInfo);
}
public async Task TransactionDetected(TransactionDetectedRequest request)
{
_logger.LogInformation("OnTransactionDetected: {TxId}", request.TxId);
if (OnTransactionDetected is null) return;
await OnTransactionDetected.Invoke(this, request);
}
public async Task NewBlock(string block)
{
_logger.LogInformation("NewBlock: {Block}", block);
if (OnNewBlock is null) return;
await OnNewBlock.Invoke(this, block);
}
public async Task StartListen(string key)
{
await AssertPermission(key, APIKeyPermission.Read);
_serviceProvider
.GetRequiredService<LightningNodeManager>().Node?
.GetServiceProvider()
.GetRequiredService<BTCPayPaymentsNotifier>()
.StartListen();
}
private async Task AssertPermission(string key, APIKeyPermission permission)
{
if (ApiKeyManager is null)
throw new HubException("Api Key Manager not available");
if (!await ApiKeyManager.CheckPermission(key, permission))
throw new HubException("Permission denied");
}
public async Task<LightningInvoice> CreateInvoice(string key, CreateLightningInvoiceRequest createLightningInvoiceRequest)
{
await AssertPermission(key, APIKeyPermission.Read);
if (PaymentsManager is null) throw new HubException("Payments Manager not available");
var descHash = new uint256(Hashes.SHA256(Encoding.UTF8.GetBytes(createLightningInvoiceRequest.Description)),
false);
return (await PaymentsManager.RequestPayment(createLightningInvoiceRequest.Amount,
createLightningInvoiceRequest.Expiry, descHash)).ToInvoice();
}
public async Task<LightningInvoice?> GetLightningInvoice(string key, uint256 paymentHash)
{
await AssertPermission(key, APIKeyPermission.Read);
if (PaymentsManager is null) throw new HubException("Payments Manager not available");
var invoices = await PaymentsManager.List(payments =>
payments.Where(payment => payment.Inbound && payment.PaymentHash == paymentHash));
return invoices.FirstOrDefault()?.ToInvoice();
}
public async Task<LightningPayment?> GetLightningPayment(string key, uint256 paymentHash)
{
await AssertPermission(key, APIKeyPermission.Read);
if (PaymentsManager is null) throw new HubException("Payments Manager not available");
var invoices = await PaymentsManager.List(payments =>
payments.Where(payment => !payment.Inbound && payment.PaymentHash == paymentHash));
return invoices.FirstOrDefault()?.ToPayment();
}
public async Task CancelInvoice(string key, uint256 paymentHash)
{
await AssertPermission(key, APIKeyPermission.Write);
if (PaymentsManager is null) throw new HubException("Payments Manager not available");
await PaymentsManager.CancelInbound(paymentHash);
}
public async Task<List<LightningPayment>> GetLightningPayments(string key, ListPaymentsParams request)
{
await AssertPermission(key, APIKeyPermission.Read);
if (PaymentsManager is null) throw new HubException("Payments Manager not available");
return await PaymentsManager.List(payments => payments.Where(payment => !payment.Inbound))
.ToPayments();
}
public async Task<List<LightningInvoice>> GetLightningInvoices(string key, ListInvoicesParams request)
{
await AssertPermission(key, APIKeyPermission.Read);
if (PaymentsManager is null) throw new HubException("Payments Manager not available");
return await PaymentsManager.List(payments => payments.Where(payment => payment.Inbound)).ToInvoices();
}
public async Task<PayResponse> PayInvoice(string key, string bolt11, long? amountMilliSatoshi)
{
await AssertPermission(key, APIKeyPermission.Write);
if (PaymentsManager is null) throw new HubException("Payments Manager not available");
var config = await _serviceProvider.GetRequiredService<OnChainWalletManager>().GetConfig();
var network = config?.NBitcoinNetwork;
if (network is null) throw new HubException("Network info not available");
var bolt = BOLT11PaymentRequest.Parse(bolt11, network);
try
{
var result = await PaymentsManager.PayInvoice(bolt,
amountMilliSatoshi is null ? null : LightMoney.MilliSatoshis(amountMilliSatoshi.Value));
return new PayResponse
{
Result = result.Status switch
{
LightningPaymentStatus.Unknown => PayResult.Unknown,
LightningPaymentStatus.Pending => PayResult.Unknown,
LightningPaymentStatus.Complete => PayResult.Ok,
LightningPaymentStatus.Failed => PayResult.Error,
_ => throw new ArgumentOutOfRangeException()
},
Details = new PayDetails
{
Preimage = result.Preimage is not null ? new uint256(result.Preimage) : null,
Status = result.Status
}
};
}
catch (Exception e)
{
_logger.LogError(e, "Error paying invoice");
return new PayResponse(PayResult.Error, e.Message);
}
}
public Task MasterUpdated(long? deviceIdentifier)
{
_logger.LogInformation("MasterUpdated: {DeviceIdentifier}", deviceIdentifier);
OnMasterUpdated?.Invoke(this, deviceIdentifier);
return Task.CompletedTask;
}
public async Task<LightningNodeInformation> GetLightningNodeInfo(string key)
{
await AssertPermission(key, APIKeyPermission.Read);
if (Node is null) throw new HubException("Lightning Node not available");
var config = await Node.GetConfig();
var peers = await Node.GetPeers();
var chans = await Node.GetChannels() ?? [];
var channels = chans
.Where(channel => channel.channelDetails is not null)
.Select(channel => channel.channelDetails)
.OfType<ChannelDetails>()
.ToArray();
var bb = await _serviceProvider.GetRequiredService<OnChainWalletManager>().GetBestBlock();
return new LightningNodeInformation
{
Alias = config.Alias,
Color = config.Color,
Version = "preprepreprealpha",
BlockHeight = bb?.BlockHeight ?? 0,
PeersCount = peers.Length,
ActiveChannelsCount = channels.Count(channel => channel.get_is_usable()),
PendingChannelsCount = channels.Count(channel => !channel.get_is_usable() && !channel.get_is_channel_ready()),
InactiveChannelsCount = channels.Count(channel => !channel.get_is_usable() && channel.get_is_channel_ready())
};
}
public async Task<LightningNodeBalance> GetLightningBalance(string key)
{
await AssertPermission(key, APIKeyPermission.Read);
if (Node is null) throw new HubException("Lightning Node not available");
var chans = await Node.GetChannels() ?? [];
var channels = chans
.Where(channel => channel.channelDetails is not null)
.Select(channel => channel.channelDetails)
.OfType<ChannelDetails>()
.ToArray();
var balances = Node.ClaimableBalances;
var closing = balances
.Where(b => b is Balance.Balance_ClaimableAwaitingConfirmations)
.ToArray();
return new LightningNodeBalance
{
OffchainBalance = new OffchainBalance
{
Local = LightMoney.MilliSatoshis(channels.Sum(channel => channel.get_outbound_capacity_msat())),
Remote = LightMoney.MilliSatoshis(channels.Sum(channel => channel.get_inbound_capacity_msat())),
Closing = LightMoney.Satoshis(closing.Sum(balance => balance.claimable_amount_satoshis()))
}
};
}
}

View File

@ -1,389 +0,0 @@
using System.Net;
using System.Net.WebSockets;
using BTCPayApp.Core.Auth;
using BTCPayApp.Core.Backup;
using BTCPayApp.Core.Contracts;
using BTCPayApp.Core.Helpers;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NBitcoin;
using TypedSignalR.Client;
namespace BTCPayApp.Core.BTCPayServer;
public class BTCPayConnectionManager(
IServiceProvider serviceProvider,
IAccountManager accountManager,
AuthenticationStateProvider authStateProvider,
ILogger<BTCPayConnectionManager> logger,
BTCPayAppServerClient btcPayAppServerClient,
IBTCPayAppHubClient btcPayAppServerClientInterface,
ConfigProvider configProvider,
SyncService syncService)
: BaseHostedService(logger), IHubConnectionObserver
{
private BTCPayConnectionState _connectionState = BTCPayConnectionState.Init;
private CancellationTokenSource _cts = new();
private readonly SemaphoreSlim _lock = new(1, 1);
private IDisposable? _subscription;
private IBTCPayAppHubServer? _hubProxy;
public IBTCPayAppHubServer? HubProxy
{
get => Connection?.State == HubConnectionState.Connected ? _hubProxy : null;
private set => _hubProxy = value;
}
private HubConnection? Connection { get; set; }
public Network? ReportedNetwork { get; private set; }
public string? ReportedNodeInfo { get; set; }
private bool ForceSlaveMode { get; set; }
public bool RunningInBackground { get; set; }
public event AsyncEventHandler<(BTCPayConnectionState Old, BTCPayConnectionState New)>? ConnectionChanged;
public BTCPayConnectionState ConnectionState
{
get => _connectionState;
private set
{
_lock.Wait();
try
{
if (_connectionState == value) return;
var old = _connectionState;
_connectionState = value;
logger.LogInformation("Connection state changed{BgInfo}: {Old} -> {ConnectionState}", BgInfo, old, _connectionState);
ConnectionChanged?.Invoke(this, (old, _connectionState));
}
finally
{
_lock.Release();
}
}
}
protected override async Task ExecuteStartAsync(CancellationToken cancellationToken)
{
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
ConnectionChanged += OnConnectionChanged;
authStateProvider.AuthenticationStateChanged += OnAuthenticationStateChanged;
btcPayAppServerClient.OnNotifyNetwork += OnNotifyNetwork;
btcPayAppServerClient.OnNotifyServerEvent += OnNotifyServerEvent;
btcPayAppServerClient.OnServerNodeInfo += OnServerNodeInfo;
btcPayAppServerClient.OnMasterUpdated += OnMasterUpdated;
accountManager.OnEncryptionKeyChanged += OnEncryptionKeyChanged;
await OnConnectionChanged(this, (BTCPayConnectionState.Init, BTCPayConnectionState.Init));
}
private async Task OnMasterUpdated(object? sender, long? masterId)
{
await WrapInLock(async () =>
{
if (_cts.IsCancellationRequested)
return;
var deviceId = await configProvider.GetDeviceIdentifier();
if (masterId is null && ConnectionState == BTCPayConnectionState.ConnectedAsSecondary && !ForceSlaveMode)
{
logger.LogInformation("OnMasterUpdated{BgInfo}: Syncing slave {DeviceId}", BgInfo, deviceId);
ConnectionState = BTCPayConnectionState.Syncing;
}
else if (deviceId == masterId)
{
logger.LogInformation("OnMasterUpdated{BgInfo}: Setting master to {DeviceId}", BgInfo, deviceId);
ConnectionState = BTCPayConnectionState.ConnectedAsPrimary;
}
else if (ConnectionState == BTCPayConnectionState.ConnectedAsPrimary && masterId != deviceId)
{
logger.LogInformation("OnMasterUpdated{BgInfo}: New master {MasterId} - Device: {DeviceId}", BgInfo, masterId, deviceId);
ConnectionState = BTCPayConnectionState.Syncing;
}
}, _cts.Token);
}
private async Task OnEncryptionKeyChanged(object? sender, string encryptionKey)
{
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
await WrapInLock(async () =>
{
if (_connectionState == BTCPayConnectionState.WaitingForEncryptionKey)
{
ConnectionState = BTCPayConnectionState.Syncing;
}
}, _cts.Token);
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
}
private async Task OnConnectionChanged(object? sender, (BTCPayConnectionState Old, BTCPayConnectionState New) e)
{
var deviceIdentifier = await configProvider.GetDeviceIdentifier();
var newState = e.New;
try
{
var account = accountManager.Account;
switch (e.New)
{
case BTCPayConnectionState.Init:
newState = BTCPayConnectionState.WaitingForAuth;
break;
case BTCPayConnectionState.WaitingForAuth:
await syncService.StopSync();
if (account is not null && await accountManager.CheckAuthenticated())
{
newState = BTCPayConnectionState.Connecting;
}
break;
case BTCPayConnectionState.Connecting:
if (account is null)
{
newState = BTCPayConnectionState.WaitingForAuth;
break;
}
await Kill();
var url = new Uri(new Uri(account.BaseUri), "hub/btcpayapp").ToString();
var connection = new HubConnectionBuilder()
.AddNewtonsoftJsonProtocol(options =>
{
NBitcoin.JsonConverters.Serializer.RegisterFrontConverters(options.PayloadSerializerSettings);
options.PayloadSerializerSettings.Converters.Add(new global::BTCPayServer.Lightning.JsonConverters.LightMoneyJsonConverter());
})
.WithUrl(url, options =>
{
options.AccessTokenProvider = () =>
Task.FromResult(accountManager.Account?.OwnerToken);
options.HttpMessageHandlerFactory = serviceProvider
.GetService<Func<HttpMessageHandler, HttpMessageHandler>>();
options.WebSocketConfiguration =
serviceProvider.GetService<Action<ClientWebSocketOptions>>();
})
.Build();
_subscription = connection.Register(btcPayAppServerClientInterface);
HubProxy = new ExceptionWrappedHubProxy(connection, logger);
if (connection.State == HubConnectionState.Disconnected)
{
try
{
connection.Closed += OnClosed;
connection.Reconnected += OnReconnected;
connection.Reconnecting += OnReconnecting;
await connection.StartAsync();
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized)
{
await accountManager.Logout();
logger.LogInformation("Signed out user because of unauthorized response{BgInfo}", BgInfo);
}
catch (Exception ex)
{
await Task.Delay(500);
if (ex is not TaskCanceledException)
logger.LogError("Error while connecting to hub{BgInfo}: {Message}", BgInfo, ex.Message);
}
}
Connection = connection;
newState = Connection.State switch
{
HubConnectionState.Connected => BTCPayConnectionState.Syncing,
HubConnectionState.Connecting => BTCPayConnectionState.Connecting,
_ => BTCPayConnectionState.WaitingForAuth
};
break;
case BTCPayConnectionState.Syncing:
await syncService.StopSync();
if (await syncService.EncryptionKeyRequiresImport())
{
newState = BTCPayConnectionState.WaitingForEncryptionKey;
logger.LogWarning(
"Existing state found but encryption key is missing, waiting until key is provided");
}
else
{
//check if we are the master previously to process outbox items
var masterDevice = await HubProxy!.GetCurrentMaster();
if (deviceIdentifier == masterDevice)
{
logger.LogInformation("Syncing master to remote{BgInfo}: {DeviceId}", BgInfo, deviceIdentifier);
await syncService.SyncToRemote(CancellationToken.None);
}
else
{
logger.LogInformation("Syncing to local{BgInfo}. Master: {MasterId} - Device: {DeviceId}", BgInfo, masterDevice, deviceIdentifier);
await syncService.SyncToLocal();
}
newState = BTCPayConnectionState.ConnectedFinishedInitialSync;
var config = await configProvider.Get<BTCPayAppConfig>(BTCPayAppConfig.Key);
if (!string.IsNullOrEmpty(config?.CurrentStoreId))
{
await accountManager.SetCurrentStoreId(config.CurrentStoreId);
}
}
break;
case BTCPayConnectionState.ConnectedFinishedInitialSync:
if (ForceSlaveMode)
{
await HubProxy!.DeviceMasterSignal(deviceIdentifier, false);
ForceSlaveMode = false;
newState = BTCPayConnectionState.ConnectedAsSecondary;
}
else if (!await HubProxy!.DeviceMasterSignal(deviceIdentifier, true))
{
newState = BTCPayConnectionState.ConnectedAsSecondary;
}
break;
case BTCPayConnectionState.ConnectedAsPrimary:
await syncService.StartSync(false);
break;
case BTCPayConnectionState.ConnectedAsSecondary:
await syncService.StartSync(true);
break;
case BTCPayConnectionState.Disconnected:
newState = BTCPayConnectionState.WaitingForAuth;
break;
}
}
catch (System.Security.Cryptography.CryptographicException ex) when (newState is BTCPayConnectionState.Syncing or BTCPayConnectionState.Connecting)
{
logger.LogError(ex, "Error while changing connection state from {Old} to {New}{BgInfo}", e.Old, e.New, BgInfo);
newState = BTCPayConnectionState.WaitingForEncryptionKey;
}
catch (Exception ex)
{
logger.LogError(ex, "Error while changing connection state from {Old} to {New}{BgInfo}", e.Old, e.New, BgInfo);
throw;
}
finally
{
_ = Task.Run(() => ConnectionState = newState);
}
}
private Task OnServerNodeInfo(object? sender, string? e)
{
ReportedNodeInfo = e;
return Task.CompletedTask;
}
private Task OnNotifyServerEvent(object? sender, ServerEvent e)
{
logger.LogInformation("OnNotifyServerEvent{BgInfo}: {Type} - {Details}", BgInfo, e.Type, e.ToString());
return Task.CompletedTask;
}
private Task OnNotifyNetwork(object? sender, string e)
{
ReportedNetwork = Network.GetNetwork(e);
return Task.CompletedTask;
}
private async void OnAuthenticationStateChanged(Task<AuthenticationState> task)
{
await WrapInLock(async () =>
{
try
{
await task;
var authState = await accountManager.CheckAuthenticated();
if (ConnectionState == BTCPayConnectionState.WaitingForAuth && authState)
{
ConnectionState = BTCPayConnectionState.Connecting;
}
else if (ConnectionState > BTCPayConnectionState.WaitingForAuth && !authState)
{
ConnectionState = BTCPayConnectionState.WaitingForAuth;
}
}
catch (Exception e)
{
logger.LogError(e, "Error while handling authentication state change{BgInfo}", BgInfo);
}
}, _cts.Token);
}
private async Task Kill()
{
if (Connection is not null)
{
logger.LogWarning("Killing connection{BgInfo}", BgInfo);
}
var conn = Connection;
Connection = null;
if (conn is not null)
{
conn.Closed -= OnClosed;
conn.Reconnected -= OnReconnected;
conn.Reconnecting -= OnReconnecting;
await conn.StopAsync();
}
_subscription?.Dispose();
_subscription = null;
HubProxy = null;
await syncService.StopSync();
}
protected override async Task ExecuteStopAsync(CancellationToken cancellationToken)
{
await _cts.CancelAsync();
if (_connectionState == BTCPayConnectionState.ConnectedAsPrimary)
{
var deviceId = await configProvider.GetDeviceIdentifier();
logger.LogInformation("Sending device master signal to turn off {DeviceId}{BgInfo}", deviceId, BgInfo);
await syncService.StopSync();
await syncService.SyncToRemote(CancellationToken.None);
if (HubProxy is not null)
{
await HubProxy.DeviceMasterSignal(deviceId, false);
}
}
await Kill();
authStateProvider.AuthenticationStateChanged -= OnAuthenticationStateChanged;
btcPayAppServerClient.OnNotifyNetwork -= OnNotifyNetwork;
accountManager.OnEncryptionKeyChanged -= OnEncryptionKeyChanged;
ConnectionChanged -= OnConnectionChanged;
}
public Task OnClosed(Exception? ex)
{
logger.LogError("Hub connection closed{BgInfo}: {Message}", BgInfo, ex?.Message);
if (Connection?.State == HubConnectionState.Disconnected && ConnectionState != BTCPayConnectionState.Connecting)
{
ConnectionState = BTCPayConnectionState.Disconnected;
}
return Task.CompletedTask;
}
public Task OnReconnected(string? connectionId)
{
logger.LogInformation("Hub connection reconnected{BgInfo}", BgInfo);
ConnectionState = BTCPayConnectionState.Syncing;
return Task.CompletedTask;
}
public Task OnReconnecting(Exception? ex)
{
logger.LogWarning("Hub connection reconnecting{BgInfo}: {Message}", BgInfo, ex?.Message);
ConnectionState = BTCPayConnectionState.Connecting;
return Task.CompletedTask;
}
public async Task SwitchToSecondary()
{
if (_connectionState == BTCPayConnectionState.ConnectedAsPrimary)
{
ForceSlaveMode = true;
var deviceId = await configProvider.GetDeviceIdentifier();
logger.LogInformation("Sending device master signal to turn off {DeviceId}", deviceId);
await syncService.StopSync();
await syncService.SyncToRemote(CancellationToken.None);
await HubProxy!.DeviceMasterSignal(deviceId, false);
}
}
private string BgInfo => RunningInBackground ? " (in background mode)" : string.Empty;
}

View File

@ -1,17 +0,0 @@
using System.Text.Json.Serialization;
namespace BTCPayApp.Core.BTCPayServer;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum BTCPayConnectionState
{
Init,
Disconnected,
WaitingForAuth,
Connecting,
Syncing,
WaitingForEncryptionKey,
ConnectedAsPrimary,
ConnectedAsSecondary,
ConnectedFinishedInitialSync
}

View File

@ -1,43 +0,0 @@
using BTCPayApp.Core.Data;
using BTCPayApp.Core.Helpers;
using BTCPayApp.Core.LDK;
namespace BTCPayApp.Core.BTCPayServer;
public class BTCPayPaymentsNotifier(
PaymentsManager paymentsManager,
BTCPayConnectionManager connectionManager)
: IScopedHostedService
{
private bool _listening;
public Task StartAsync(CancellationToken cancellationToken)
{
paymentsManager.OnPaymentUpdate += OnPaymentUpdate;
connectionManager.ConnectionChanged += ConnectionManagerOnConnectionChanged;
return Task.CompletedTask;
}
private Task ConnectionManagerOnConnectionChanged(object? sender, (BTCPayConnectionState Old, BTCPayConnectionState New) e)
{
_listening = false;
return Task.CompletedTask;
}
private async Task OnPaymentUpdate(object? sender, AppLightningPayment e)
{
if (!_listening || connectionManager.HubProxy is null) return;
await connectionManager.HubProxy.SendInvoiceUpdate(e.ToInvoice());
}
public Task StopAsync(CancellationToken cancellationToken)
{
paymentsManager.OnPaymentUpdate -= OnPaymentUpdate;
return Task.CompletedTask;
}
public void StartListen()
{
_listening = true;
}
}

View File

@ -1,112 +0,0 @@
using BTCPayApp.Core.Helpers;
using BTCPayServer.Lightning;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
using TypedSignalR.Client;
namespace BTCPayApp.Core.BTCPayServer;
public class ExceptionWrappedHubProxy : IBTCPayAppHubServer
{
private readonly IBTCPayAppHubServer _hubProxy;
private readonly ILogger _logger;
public ExceptionWrappedHubProxy(HubConnection connection, ILogger logger)
{
_hubProxy = connection.CreateHubProxy<IBTCPayAppHubServer>();
_logger = logger;
}
private async Task<T> Wrap<T>(Func<Task<T>> func)
{
return await AsyncExtensions.RunInOtherThread(async () =>
{
// executes in thread pool
try
{
return await func();
}
catch (InvalidOperationException e)
{
_logger.LogError(e, $"Error while calling hub method");
return default!;
}
catch (Exception e)
{
_logger.LogError(e, "Error while calling hub method");
return default!;
}
}).Unwrap();
}
public async Task<bool> DeviceMasterSignal(long deviceIdentifier, bool active)
{
return await Wrap(async () => await _hubProxy.DeviceMasterSignal(deviceIdentifier, active));
}
public async Task<Dictionary<string, string>> Pair(PairRequest request)
{
return await Wrap(async () => await _hubProxy.Pair(request));
}
public async Task<AppHandshakeResponse> Handshake(AppHandshake request)
{
return await Wrap(async () => await _hubProxy.Handshake(request));
}
public async Task<bool> BroadcastTransaction(string tx)
{
return await Wrap(async () => await _hubProxy.BroadcastTransaction(tx));
}
public async Task<decimal> GetFeeRate(int blockTarget)
{
return await Wrap(async () => await _hubProxy.GetFeeRate(blockTarget));
}
public async Task<BestBlockResponse?> GetBestBlock()
{
return await Wrap(async () => await _hubProxy.GetBestBlock());
}
public async Task<TxInfoResponse> FetchTxsAndTheirBlockHeads(string identifier, string[] txIds, string[] outpoints)
{
return await Wrap(async () => await _hubProxy.FetchTxsAndTheirBlockHeads(identifier, txIds, outpoints));
}
public async Task<ScriptResponse> DeriveScript(string identifier)
{
return await Wrap(async () => await _hubProxy.DeriveScript(identifier));
}
public async Task TrackScripts(string identifier, string[] scripts)
{
await Wrap(() => Task.FromResult(_hubProxy.TrackScripts(identifier, scripts)));
}
public async Task<string> UpdatePsbt(string[] identifiers, string psbt)
{
return await Wrap(async () => await _hubProxy.UpdatePsbt(identifiers, psbt));
}
public async Task<Dictionary<string, CoinResponse[]>> GetUTXOs(string[] identifiers)
{
return await Wrap(async () => await _hubProxy.GetUTXOs(identifiers));
}
public async Task<Dictionary<string, TxResp[]>> GetTransactions(string[] identifiers)
{
return await Wrap(async () => await _hubProxy.GetTransactions(identifiers));
}
public async Task SendInvoiceUpdate(LightningInvoice lightningInvoice)
{
await Wrap(() => Task.FromResult(_hubProxy.SendInvoiceUpdate(lightningInvoice)));
}
public async Task<long?> GetCurrentMaster()
{
return await Wrap(async () => await _hubProxy.GetCurrentMaster());
}
}

View File

@ -1,137 +0,0 @@
using BTCPayServer.Client.Models;
using BTCPayServer.Lightning;
using NBitcoin;
namespace BTCPayApp.Core.BTCPayServer;
//methods available on the hub in the client
public interface IBTCPayAppHubClient
{
Task NotifyServerEvent(ServerEvent ev);
Task NotifyNetwork(string network);
Task NotifyServerNode(string nodeInfo);
Task TransactionDetected(TransactionDetectedRequest request);
Task NewBlock(string block);
Task StartListen(string key);
Task<LightningInvoice> CreateInvoice(string key, CreateLightningInvoiceRequest createLightningInvoiceRequest);
Task<LightningInvoice?> GetLightningInvoice(string key, uint256 paymentHash);
Task<LightningPayment?> GetLightningPayment(string key, uint256 paymentHash);
Task CancelInvoice(string key, uint256 paymentHash);
Task<List<LightningPayment>> GetLightningPayments(string key, ListPaymentsParams request);
Task<List<LightningInvoice>> GetLightningInvoices(string key, ListInvoicesParams request);
Task<PayResponse> PayInvoice(string key, string bolt11, long? amountMilliSatoshi);
Task MasterUpdated(long? deviceIdentifier);
Task<LightningNodeInformation> GetLightningNodeInfo(string key);
Task<LightningNodeBalance> GetLightningBalance(string key);
}
//methods available on the hub in the server
public interface IBTCPayAppHubServer
{
Task<bool> DeviceMasterSignal(long deviceIdentifier, bool active);
Task<Dictionary<string,string>> Pair(PairRequest request);
Task<AppHandshakeResponse> Handshake(AppHandshake request);
Task<bool> BroadcastTransaction(string tx);
Task<decimal> GetFeeRate(int blockTarget);
Task<BestBlockResponse?> GetBestBlock();
Task<TxInfoResponse> FetchTxsAndTheirBlockHeads(string identifier, string[] txIds, string[] outpoints);
Task<ScriptResponse> DeriveScript(string identifier);
Task TrackScripts(string identifier, string[] scripts);
Task<string> UpdatePsbt(string[] identifiers, string psbt);
Task<Dictionary<string, CoinResponse[]>> GetUTXOs(string[] identifiers);
Task<Dictionary<string, TxResp[]>> GetTransactions(string[] identifiers);
Task SendInvoiceUpdate(LightningInvoice lightningInvoice);
Task<long?> GetCurrentMaster();
}
public class ServerEvent
{
public string Type { get; set; } = null!;
public string? StoreId { get; set; }
public string? UserId { get; set; }
public string? AppId { get; set; }
public string? InvoiceId { get; set; }
public string? Detail { get; set; }
}
public record TxResp
{
public string TransactionId { get; set; } = null!;
public long Confirmations { get; set; }
public long? Height { get; set; }
public decimal BalanceChange { get; set; }
public DateTimeOffset Timestamp { get; set; }
public override string ToString()
{
return $"{{ Confirmations = {Confirmations}, Height = {Height}, BalanceChange = {BalanceChange}, Timestamp = {Timestamp}, TransactionId = {TransactionId} }}";
}
}
public class TransactionDetectedRequest
{
public string? Identifier { get; set; }
public string? TxId { get; set; }
public string[]? SpentScripts { get; set; }
public string[]? ReceivedScripts { get; set; }
public bool Confirmed { get; set; }
}
public class CoinResponse
{
public bool Confirmed { get; set; }
public string? Script { get; set; }
public string? Outpoint { get; set; }
public decimal Value { get; set; }
public string? Path { get; set; }
}
public class TxInfoResponse
{
public Dictionary<string,TransactionResponse>? Txs { get; set; }
public Dictionary<string,string>? BlockHeaders { get; set; }
public Dictionary<string,int>? BlockHeights { get; set; }
}
public class TransactionResponse
{
public string? BlockHash { get; set; }
public string? Transaction { get; set; }
}
public class BestBlockResponse
{
public string? BlockHash { get; set; }
public int BlockHeight { get; set; }
public string? BlockHeader { get; set; }
}
public class ScriptResponse
{
public string Script { get; set; } = null!;
public string KeyPath { get; set; } = null!;
}
public class AppHandshake
{
public string[]? Identifiers { get; set; }
}
//response about identifiers being tracked successfully
public class AppHandshakeResponse
{
public string[]? IdentifiersAcknowledged { get; set; }
}
public class PairRequest
{
public Dictionary<string, DerivationItem> Derivations { get; set; } = new();
}
public class DerivationItem
{
public string? Descriptor { get; set; }
public int Index { get; set; }
public OutPoint[] KnownCoins { get; set; } = [];
}

View File

@ -1,423 +0,0 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using BTCPayApp.Core.Auth;
using BTCPayApp.Core.Contracts;
using BTCPayApp.Core.Data;
using BTCPayApp.Core.Helpers;
using Google.Protobuf;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin;
using VSS;
using VSSProto;
namespace BTCPayApp.Core.Backup;
public class SyncService(
ConfigProvider configProvider,
ILogger<SyncService> logger,
IAccountManager accountManager,
IHttpClientFactory httpClientFactory,
IDbContextFactory<AppDbContext> dbContextFactory)
: IDisposable
{
public AsyncEventHandler<(List<Outbox> OutboxItemsProcesed, PutObjectRequest RemoteRequest)>? RemoteObjectUpdated;
public AsyncEventHandler<string[]>? LocalUpdated;
private (Task syncTask, CancellationTokenSource cts, bool local)? _syncTask;
private readonly SemaphoreSlim _syncLock = new(1, 1);
private async Task<IDataProtector?> GetDataProtector()
{
var key = await accountManager.GetEncryptionKey();
return string.IsNullOrEmpty(key) ? null : new SingleKeyDataProtector(Convert.FromHexString(key));
}
public async Task<bool> EncryptionKeyRequiresImport()
{
var dataProtector = await GetDataProtector();
if (dataProtector is not null)
return false;
var api = await GetUnencryptedVSSAPI();
try
{
var res = await api.GetObjectAsync(new GetObjectRequest
{
Key = "encryptionKeyTest"
});
if (res.Value is null or {Value.Length: 0})
return false;
if (dataProtector is null)
return true;
var decrypted = dataProtector.Unprotect(res.Value.ToByteArray());
return "kukks" == Encoding.UTF8.GetString(decrypted);
}
catch (VSSClientException e) when (e.Error.ErrorCode == ErrorCode.NoSuchKeyException)
{
return false;
}
catch (Exception e)
{
logger.LogError(e, "Error while checking if encryption key requires import");
throw;
}
}
public async Task<bool> SetEncryptionKey(Mnemonic mnemonic)
{
var key = mnemonic.DeriveExtKey().Derive(1337).PrivateKey.ToBytes();
return await SetEncryptionKey(Convert.ToHexString(key));
}
public async Task<bool> SetEncryptionKey(string key)
{
if (key.Contains(' ')) return await SetEncryptionKey(new Mnemonic(key));
var dataProtector = new SingleKeyDataProtector(Convert.FromHexString(key));
var encrypted = dataProtector.Protect("kukks"u8.ToArray());
var api = await GetUnencryptedVSSAPI();
try
{
var res = await api.GetObjectAsync(new GetObjectRequest
{
Key = "encryptionKeyTest"
});
if (res.Value is {Value.Length: > 0})
{
var decrypted = dataProtector.Unprotect(res.Value.Value.ToByteArray());
if ("kukks" == Encoding.UTF8.GetString(decrypted))
{
await accountManager.SetEncryptionKey(key);
return true;
}
return false;
}
}
catch (VSSClientException e) when (e.Error.ErrorCode == ErrorCode.NoSuchKeyException)
{
}
catch (Exception e)
{
logger.LogError("Error while setting encryption key: {Message}", e.Message);
return false;
}
await api.PutObjectAsync(new PutObjectRequest
{
GlobalVersion = await configProvider.GetDeviceIdentifier(),
TransactionItems =
{
new KeyValue
{
Key = "encryptionKeyTest",
Value = ByteString.CopyFrom(encrypted)
}
},
});
await accountManager.SetEncryptionKey(key);
return true;
}
private Task<IVSSAPI> GetUnencryptedVSSAPI()
{
var account = accountManager.Account;
if (account is null)
throw new InvalidOperationException("Account not found");
var vssUri = new Uri(new Uri(account.BaseUri), "vss/");
var httpClient = httpClientFactory.CreateClient("vss");
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", account.OwnerToken);
var vssClient = new HttpVSSAPIClient(vssUri, httpClient);
return Task.FromResult<IVSSAPI>(vssClient);
}
private async Task<IVSSAPI?> GetVSSAPI()
{
var dataProtector = await GetDataProtector();
return dataProtector is null ? null : new VSSApiEncryptorClient(await GetUnencryptedVSSAPI(), dataProtector);
}
private static async Task<KeyValue[]> CreateLocalVersions(AppDbContext dbContext)
{
var settings = dbContext.Settings.Where(setting => setting.Backup).Select(setting => new KeyValue
{
Key = setting.EntityKey,
Version = setting.Version
});
var channels = dbContext.LightningChannels.Select(channel => new KeyValue
{
Key = channel.EntityKey,
Version = channel.Version
});
var payments = dbContext.LightningPayments.Select(payment => new KeyValue
{
Key = payment.EntityKey,
Version = payment.Version
});
return await settings.Concat(channels).Concat(payments).ToArrayAsync();
}
public async Task SyncToLocal(CancellationToken cancellationToken = default)
{
var backupApi = await GetVSSAPI();
if (backupApi is null)
return;
await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var localVersions = await CreateLocalVersions(db);
var remoteVersions = await backupApi.ListKeyVersionsAsync(new ListKeyVersionsRequest(), cancellationToken);
await db.Database.BeginTransactionAsync(cancellationToken);
try
{
var triggers = await db.Database
.SqlQuery<TriggerRecord>($"SELECT name, sql FROM sqlite_master WHERE type = 'trigger'")
.ToListAsync(cancellationToken: cancellationToken);
await db.Database.ExecuteSqlRawAsync(
string.Join("; ", triggers.Select(trigger => $"DROP TRIGGER IF EXISTS {trigger.name}")),
cancellationToken: cancellationToken);
// delete local versions that are not in remote
// delete local versions which are lower than remote
var toDelete = localVersions.Where(localVersion =>
remoteVersions.KeyVersions.All(remoteVersion => remoteVersion.Key != localVersion.Key)
|| remoteVersions.KeyVersions.All(remoteVersion =>
remoteVersion.Key == localVersion.Key && remoteVersion.Version > localVersion.Version)).ToArray();
var toUpsert = remoteVersions.KeyVersions.Where(remoteVersion => localVersions.All(localVersion =>
localVersion.Key != remoteVersion.Key || localVersion.Version < remoteVersion.Version)).Where(value => value.Key != "encryptionKeyTest").ToArray();
if (toDelete.Length == 0 && toUpsert.Length == 0)
return;
logger.LogInformation("Syncing to local: {ToDelete} to delete, {ToUpsert} to upsert", toDelete.Length,
toUpsert.Length);
foreach (var upsertItem in toUpsert)
{
if (upsertItem.Value is not (null or { Length: 0 })) continue;
var item = await backupApi.GetObjectAsync(new GetObjectRequest()
{
Key = upsertItem.Key,
}, cancellationToken);
upsertItem.MergeFrom(item.Value);
}
var settingsToDelete = toDelete.Where(key => key.Key.StartsWith("Setting_")).Select(key => key.Key);
var channelsToDelete = toDelete.Where(key => key.Key.StartsWith("Channel_")).Select(key => key.Key);
var paymentsToDelete = toDelete.Where(key => key.Key.StartsWith("Payment_")).Select(key => key.Key);
var deleteCount = 0;
deleteCount += await db.Settings.Where(setting => settingsToDelete.Contains(setting.EntityKey))
.ExecuteDeleteAsync(cancellationToken: cancellationToken);
deleteCount += await db.LightningChannels.Where(channel => channelsToDelete.Contains(channel.EntityKey))
.ExecuteDeleteAsync(cancellationToken: cancellationToken);
deleteCount += await db.LightningPayments.Where(payment => paymentsToDelete.Contains(payment.EntityKey))
.ExecuteDeleteAsync(cancellationToken: cancellationToken);
// upsert the rest when needed
var settingsToUpsert = toUpsert.Where(key => key.Key.StartsWith("Setting_")).Select(setting => new Setting()
{
Key = setting.Key.Replace("Setting_", ""),
Value = setting.Value.ToByteArray(),
Version = setting.Version,
Backup = true
}).ToArray();
var channelsToUpsert = toUpsert.Where(key => key.Key.StartsWith("Channel_"))
.Select(value => JsonSerializer.Deserialize<Channel>(value.Value.ToStringUtf8())!);
var paymentsToUpsert = toUpsert.Where(key => key.Key.StartsWith("Payment_")).Select(value =>
JsonSerializer.Deserialize<AppLightningPayment>(value.Value.ToStringUtf8())!);
var upsertCount = 0;
upsertCount += await db.Settings.UpsertRange(settingsToUpsert).On(setting => setting.EntityKey)
.RunAsync(cancellationToken);
upsertCount += await db.LightningChannels.UpsertRange(channelsToUpsert).On(channel => channel.EntityKey)
.RunAsync(cancellationToken);
upsertCount += await db.LightningPayments.UpsertRange(paymentsToUpsert).On(payment => payment.EntityKey)
.RunAsync(cancellationToken);
await db.Database.ExecuteSqlRawAsync(string.Join("; ", triggers.Select(record => record.sql)),
cancellationToken: cancellationToken);
await db.SaveChangesAsync(cancellationToken);
await db.Database.CommitTransactionAsync(cancellationToken);
logger.LogInformation("Synced to local: {DeleteCount} deleted, {UpsertCount} upserted", deleteCount,
upsertCount);
LocalUpdated?.Invoke(this, toDelete.Concat(toUpsert).Select(key => key.Key).ToArray());
settingsToUpsert.Select(setting => setting.Key).Concat(settingsToDelete).Distinct().ToList()
.ForEach(key => configProvider.Updated?.Invoke(this, key));
}
catch (Exception e)
{
await db.Database.RollbackTransactionAsync(cancellationToken);
logger.LogError(e, "Error while syncing to local");
throw;
}
}
private static async Task<KeyValue?> GetValue(AppDbContext dbContext, Outbox outbox)
{
switch (outbox.Entity)
{
case "Setting":
var setting = await dbContext.Settings.SingleOrDefaultAsync(setting1 =>
setting1.EntityKey == outbox.Key && setting1.Backup);
if (setting == null)
return null;
return new KeyValue
{
Key = outbox.Key,
Value = ByteString.CopyFrom(setting.Value),
Version = setting.Version
};
case "Channel":
var channel = await dbContext.LightningChannels.Include(channel1 => channel1.Aliases)
.SingleOrDefaultAsync(channel1 => channel1.EntityKey == outbox.Key);
if (channel == null)
return null;
var val = JsonSerializer.SerializeToUtf8Bytes(channel);
return new KeyValue
{
Key = outbox.Key,
Value = ByteString.CopyFrom(val),
Version = channel.Version
};
case "Payment":
var payment = await dbContext.LightningPayments.SingleOrDefaultAsync(lightningPayment =>
lightningPayment.EntityKey == outbox.Key);
if (payment == null)
return null;
var paymentBytes = JsonSerializer.SerializeToUtf8Bytes(payment);
return new KeyValue
{
Key = outbox.Key,
Value = ByteString.CopyFrom(paymentBytes),
Version = payment.Version
};
default:
throw new ArgumentOutOfRangeException();
}
}
public async Task SyncToRemote(CancellationToken cancellationToken = default)
{
try
{
await _syncLock.WaitAsync(cancellationToken);
var backupAPi = await GetVSSAPI();
if (backupAPi is null)
return;
await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var putObjectRequest = new PutObjectRequest
{
GlobalVersion = await configProvider.GetDeviceIdentifier()
};
var outbox = await db.OutboxItems.GroupBy(outbox1 => outbox1.Key)
.ToListAsync(cancellationToken: cancellationToken);
if (outbox.Count != 0)
{
logger.LogInformation("Syncing to remote {Count} outbox items", outbox.Count);
}
var removedOutboxItems = new List<Outbox>();
foreach (var outboxItemSet in outbox)
{
var orderedEnumerable = outboxItemSet.OrderByDescending(outbox1 => outbox1.Version)
.ThenByDescending(outbox1 => outbox1.ActionType).ToArray();
foreach (var item in orderedEnumerable)
{
if (item.ActionType == OutboxAction.Delete)
{
putObjectRequest.DeleteItems.Add(new KeyValue()
{
Key = item.Key, Version = item.Version
});
}
else
{
var kv = await GetValue(db, item);
if (kv != null)
{
putObjectRequest.TransactionItems.Add(kv);
break;
}
}
}
db.OutboxItems.RemoveRange(orderedEnumerable);
removedOutboxItems.AddRange(orderedEnumerable);
// Process outbox item
}
if (putObjectRequest.TransactionItems.Count == 0 && putObjectRequest.DeleteItems.Count == 0 && _syncTask is not null) return;
await backupAPi.PutObjectAsync(putObjectRequest, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation("Synced to remote {TransactionItemsCount} items and deleted {DeleteItemsCount} items {Join}",
putObjectRequest.TransactionItems.Count,
putObjectRequest.DeleteItems.Count,
string.Join(", ", putObjectRequest.TransactionItems.Select(kv => kv.Key + " " + kv.Version)));
RemoteObjectUpdated?.Invoke(this, (removedOutboxItems, putObjectRequest.Clone()));
}
finally
{
_syncLock.Release();
}
}
public async Task StartSync(bool local,CancellationToken cancellationToken = default)
{
if (_syncTask.HasValue && _syncTask.Value.local == local && !_syncTask.Value.cts.IsCancellationRequested)
return;
if (_syncTask.HasValue && _syncTask.Value.local != local)
await _syncTask.Value.cts.CancelAsync();
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_syncTask = (ContinuouslySync(local, cts.Token), cts, local);
}
public async Task StopSync()
{
if (_syncTask.HasValue)
{
await _syncTask.Value.cts.CancelAsync();
_syncTask = null;
}
}
private async Task ContinuouslySync(bool local, CancellationToken cancellationToken = default)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
if (local)
await SyncToLocal(cancellationToken);
else
await SyncToRemote(cancellationToken);
}
catch (OperationCanceledException)
{
}
catch (Exception e)
{
logger.LogError(e, "Error while syncing to {Target}", local ? "local" : "remote");
}
finally
{
if (!cancellationToken.IsCancellationRequested)
await Task.Delay(2000, cancellationToken);
}
}
}
public void Dispose()
{
RemoteObjectUpdated = null;
LocalUpdated = null;
}
}

View File

@ -1,9 +0,0 @@
namespace BTCPayApp.Core;
public static class Constants
{
public const string LoginCodeSeparator = ";";
public const string EncryptionKeySeparator = "*";
public const string InviteSeparator = "/invite/";
public const string POSQRLoginSeparator = "loginCode";
}

View File

@ -1,16 +1,8 @@
using BTCPayApp.Core.Helpers;
namespace BTCPayApp.Core.Contracts;
namespace BTCPayApp.Core.Contracts;
public abstract class ConfigProvider : IDisposable
public interface IConfigProvider
{
public abstract Task<T?> Get<T>(string key);
public abstract Task Set<T>(string key, T? value, bool backup);
public abstract Task<IEnumerable<string>> List(string prefix);
public AsyncEventHandler<string>? Updated;
public virtual void Dispose()
{
Updated = null;
}
Task<T?> Get<T>(string key);
Task Set<T>(string key, T? value, bool backup);
Task<IEnumerable<string>> List(string prefix);
}

View File

@ -3,5 +3,4 @@
public interface IDataDirectoryProvider
{
Task<string> GetAppDataDirectory();
Task<string> GetCacheDirectory();
}
}

View File

@ -1,7 +0,0 @@
namespace BTCPayApp.Core.Contracts
{
public interface IEmailService
{
Task SendAsync(string subject, string body, string recipient, string? attachFilePath = null);
}
}

View File

@ -1,15 +0,0 @@
using BTCPayApp.Core.Models;
namespace BTCPayApp.Core.Contracts;
public interface INfcService: IDisposable
{
event EventHandler<NfcCardData> OnNfcDataReceived;
void StartNfc();
void EndNfc();
}
public class NfcCardData
{
public string Message { get; set; }
public byte[] Payload { get; set; }
}

View File

@ -1,7 +1,8 @@
namespace BTCPayApp.Core.Contracts;
public interface ISecureConfigProvider
{
Task<T?> Get<T>(string key);
Task Set<T>(string key, T? value);
}
}

View File

@ -1,12 +0,0 @@
using System.Text.Json.Serialization;
namespace BTCPayApp.Core.Contracts;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SetupState
{
Undetermined,
Pending,
Completed,
Failed
}

View File

@ -1,49 +1,57 @@
using BTCPayApp.Core.JsonConverters;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using BTCPayApp.CommonServer.Models;
using BTCPayApp.Core.JsonConverters;
using BTCPayApp.Core.LDK;
using BTCPayServer.Lightning;
using Laraue.EfCoreTriggers.Common.Extensions;
using Laraue.EfCoreTriggers.Common.TriggerBuilders.Actions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using NBitcoin;
namespace BTCPayApp.Core.Data;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Setting> Settings { get; set; }
public DbSet<Channel> LightningChannels { get; set; }
public DbSet<ChannelAlias> ChannelAliases { get; set; }
public DbSet<AppLightningPayment> LightningPayments { get; set; }
public DbSet<Outbox> OutboxItems { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Outbox>()
.HasKey(w => new {w.Entity, w.Key, w.ActionType, w.Version});
modelBuilder.Entity<Outbox>().Property(payment => payment.Timestamp).HasDefaultValueSql("datetime('now')");
modelBuilder.Entity<AppLightningPayment>().HasIndex(payment => payment.EntityKey).IsUnique();
modelBuilder.Entity<Setting>().HasIndex(payment => payment.EntityKey).IsUnique();
modelBuilder.Entity<Channel>().HasIndex(payment => payment.EntityKey).IsUnique();
modelBuilder.Entity<AppLightningPayment>().Property(payment => payment.PaymentRequest)
.HasConversion(
request => request!.ToString(),
request => request.ToString(),
str => NetworkHelper.Try(network => BOLT11PaymentRequest.Parse(str, network)));
modelBuilder.Entity<AppLightningPayment>().Property(payment => payment.Secret)
.HasConversion(
request => request!.ToString(),
request => request.ToString(),
str => uint256.Parse(str));
modelBuilder.Entity<AppLightningPayment>().Property(payment => payment.PaymentHash)
.HasConversion(
request => request!.ToString(),
request => request.ToString(),
str => uint256.Parse(str));
modelBuilder.Entity<AppLightningPayment>().Property(payment => payment.Value)
.HasConversion(
request => request!.MilliSatoshi,
request => request.MilliSatoshi,
str => new LightMoney(str));
modelBuilder.Entity<Channel>().Property(channel => channel.AdditionalData).HasJsonConversion();
modelBuilder.Entity<AppLightningPayment>().Property(payment => payment.AdditionalData).HasJsonConversion();
modelBuilder.Entity<AppLightningPayment>()
.HasKey(w => new {w.PaymentHash, w.Inbound, w.PaymentId});
@ -69,7 +77,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
.Condition(@ref => @ref.New.Backup)
.Insert(
// .InsertIfNotExists( (@ref, outbox) => outbox.Version == @ref.New.Version && outbox.ActionType == OutboxAction.Insert && outbox.Entity == "Setting" && outbox.Key == @ref.New.Key,
@ref => new Outbox
@ref => new Outbox()
{
Entity = "Setting",
Version = @ref.New.Version,
@ -82,7 +90,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
.Condition(@ref => @ref.Old.Backup)
.Insert(
// .InsertIfNotExists( (@ref, outbox) => @ref.Old.Version == outbox.Version && outbox.ActionType == OutboxAction.Delete && outbox.Entity == "Setting" && outbox.Key == @ref.Old.Key,
@ref => new Outbox
@ref => new Outbox()
{
Entity = "Setting",
Version = @ref.Old.Version,
@ -95,10 +103,10 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
// .Condition(@ref => @ref.Old.Value != @ref.New.Value)
.Update<Setting>(
(tableRefs, setting) => tableRefs.Old.Key == setting.Key,
(tableRefs, setting) => new Setting { Key = tableRefs.Old.Key, Version = tableRefs.Old.Version + 1 })
(tableRefs, setting) => new Setting() {Version = tableRefs.Old.Version + 1})
.Insert(
// .InsertIfNotExists( (@ref, outbox) => @ref.New.Version == outbox.Version && outbox.ActionType == OutboxAction.Update && outbox.Entity == "Setting" && outbox.Key == @ref.New.Key,
@ref => new Outbox
@ref => new Outbox()
{
Entity = "Setting",
Version = @ref.Old.Version + 1,
@ -122,7 +130,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
.Action(group => group
.Insert(
// .InsertIfNotExists( (@ref, outbox) => outbox.Version == @ref.New.Version && outbox.ActionType == OutboxAction.Insert && outbox.Entity == "Channel" && outbox.Key == @ref.New.Id,
@ref => new Outbox
@ref => new Outbox()
{
Entity = "Channel",
Version = @ref.New.Version,
@ -133,7 +141,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
.Action(group => group
.Insert(
// .InsertIfNotExists( (@ref, outbox) => @ref.Old.Version == outbox.Version && outbox.ActionType == OutboxAction.Delete && outbox.Entity == "Channel" && outbox.Key == @ref.Old.Id,
@ref => new Outbox
@ref => new Outbox()
{
Entity = "Channel",
Version = @ref.Old.Version,
@ -143,9 +151,9 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
.AfterUpdate(trigger => trigger
.Action(group => group.Update<Channel>(
(tableRefs, setting) => tableRefs.Old.Id == setting.Id,
(tableRefs, setting) => new Channel { Id = tableRefs.Old.Id, Version = tableRefs.Old.Version + 1 }).Insert(
(tableRefs, setting) => new Channel() {Version = tableRefs.Old.Version + 1}).Insert(
// .InsertIfNotExists( (@ref, outbox) => @ref.New.Version == outbox.Version && outbox.ActionType == OutboxAction.Update && outbox.Entity == "Channel" && outbox.Key == @ref.New.Id,
@ref => new Outbox
@ref => new Outbox()
{
Entity = "Channel",
Version = @ref.Old.Version +1,
@ -158,7 +166,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
.Action(group => group
.Insert(
// .InsertIfNotExists( (@ref, outbox) => outbox.Version == @ref.New.Version && outbox.ActionType == OutboxAction.Insert && outbox.Entity == "Payment" && outbox.Key == @ref.New.PaymentHash+ "_"+@ref.New.PaymentId+ "_"+@ref.New.Inbound,
@ref => new Outbox
@ref => new Outbox()
{
Entity = "Payment",
Version = @ref.New.Version,
@ -169,7 +177,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
.Action(group => group
.Insert(
// .InsertIfNotExists( (@ref, outbox) => @ref.Old.Version == outbox.Version && outbox.ActionType == OutboxAction.Delete && outbox.Entity == "Payment" && outbox.Key == @ref.Old.PaymentHash+ "_"+@ref.Old.PaymentId+ "_"+@ref.Old.Inbound,
@ref => new Outbox
@ref => new Outbox()
{
Entity = "Payment",
Version = @ref.Old.Version,
@ -177,14 +185,14 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
ActionType = OutboxAction.Delete
})))
.AfterUpdate(trigger => trigger
.Action(group =>
.Action(group =>
group.Update<AppLightningPayment>(
(tableRefs, setting) => tableRefs.Old.PaymentHash == setting.PaymentHash,
(tableRefs, setting) => new AppLightningPayment {Version = tableRefs.Old.Version + 1}).Insert(
// .InsertIfNotExists( (@ref, outbox) =>
(tableRefs, setting) => new AppLightningPayment() {Version = tableRefs.Old.Version + 1}).Insert(
// .InsertIfNotExists( (@ref, outbox) =>
// outbox.Version != @ref.New.Version || outbox.ActionType != OutboxAction.Update || outbox.Entity != "Payment" || outbox.Key != @ref.New.PaymentHash+ "_"+@ref.New.PaymentId+ "_"+@ref.New.Inbound,
@ref => new Outbox
@ref => new Outbox()
{
Entity = "Payment",
Version = @ref.Old.Version +1,
@ -193,4 +201,4 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
})));
base.OnModelCreating(modelBuilder);
}
}
}

View File

@ -9,13 +9,13 @@ namespace BTCPayApp.Core.Data;
public class AppLightningPayment : VersionedData
{
[JsonConverter(typeof(UInt256JsonConverter))]
public uint256? PaymentHash { get; set; }
public uint256 PaymentHash { get; set; }
public string? PaymentId { get; set; }
public string PaymentId { get; set; }
public string? Preimage { get; set; }
[JsonConverter(typeof(UInt256JsonConverter))]
public uint256? Secret { get; set; }
public uint256 Secret { get; set; }
public bool Inbound { get; set; }
@ -23,13 +23,13 @@ public class AppLightningPayment : VersionedData
public DateTimeOffset Timestamp { get; set; }
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney? Value { get; set; }
public LightMoney Value { get; set; }
[JsonConverter(typeof(JsonStringEnumConverter))]
public LightningPaymentStatus Status { get; set; }
[JsonConverter(typeof(BOLT11PaymentRequestJsonConverter))]
public BOLT11PaymentRequest? PaymentRequest { get; set; }
public BOLT11PaymentRequest PaymentRequest { get; set; }
[JsonExtensionData] public Dictionary<string, JsonElement> AdditionalData { get; set; } = new();
@ -38,4 +38,4 @@ public class AppLightningPayment : VersionedData
get => $"Payment_{PaymentHash}_{PaymentId}_{Inbound}";
init { }
}
}
}

View File

@ -1,17 +1,12 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
namespace BTCPayApp.Core.Data;
public class Channel:VersionedData
{
public required string Id { get; init; }
public byte[]? Data { get; set; }
public List<ChannelAlias> Aliases { get; set; } = [];
public long Checkpoint { get; set; }
public bool Archived { get; set; }
[JsonExtensionData] public Dictionary<string, JsonElement> AdditionalData { get; set; } = new();
public string Id { get; set; }
public byte[] Data { get; set; }
public List<ChannelAlias> Aliases { get; set; }
public override string EntityKey
{
@ -22,11 +17,11 @@ public class Channel:VersionedData
public class ChannelAlias
{
public required string Id { get; init; }
public required string Type { get; init; }
public string? ChannelId { get; set; }
public string Id { get; set; }
public string Type { get; set; }
public string ChannelId { get; set; }
[JsonIgnore]
public Channel? Channel { get; set; }
public Channel Channel { get; set; }
}

View File

@ -9,7 +9,6 @@ public class LightningConfig
public string ScriptDerivationKey { get; set; } = WalletDerivation.NativeSegwit; //when ldk asks for an address, where do we get it from?
public string LightningDerivationPath { get; set; } = "m/666'";// your lightning node derivation path
public string Color { get; set; } = "#51B13E";
public Uri? RapidGossipSyncUrl { get; set; }
public string? JITLSP { get; set; } // Just In Time Lightning Service Provider
@ -18,7 +17,7 @@ public class LightningConfig
{
get
{
if (string.IsNullOrEmpty(Color)){ return [0,0,0];}
if(string.IsNullOrEmpty(Color)){ return [0,0,0];}
if (Color.StartsWith("#"))
{
@ -39,8 +38,8 @@ public class LightningConfig
}
}
}
public Dictionary<string, PeerInfo> Peers { get; set; } = new();
public bool AcceptInboundConnection{ get; set; }
}
}

View File

@ -1,8 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace BTCPayApp.Core.Data;
public class LogDbContext(DbContextOptions<LogDbContext> options) : DbContext(options)
{
public DbSet<LogEntry> Logs { get; set; }
}

View File

@ -1,13 +0,0 @@
using Serilog.Events;
namespace BTCPayApp.Core.Data;
public class LogEntry
{
public int Id { get; set; }
public string Level { get; set; }
public DateTime TimeStamp { get; set; }
public string RenderedMessage { get; set; }
public string Exception { get; set; }
public string Properties { get; set; }
}

View File

@ -1,42 +0,0 @@
using BTCPayApp.Core.Contracts;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using Serilog.Events;
namespace BTCPayApp.Core.Data;
public static class LoggingConfig
{
private const string OutputTemplate = "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} ({SourceContext}){NewLine}{Exception}";
public static void ConfigureLogging(IServiceCollection serviceCollection)
{
var serviceProvider = serviceCollection.BuildServiceProvider();
var dirProvider = serviceProvider.GetRequiredService<IDataDirectoryProvider>();
var appDir = dirProvider.GetAppDataDirectory().ConfigureAwait(false).GetAwaiter().GetResult();
var dbPath = $"{appDir}/logs.db";
var isDevEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development";
var minLogLevel = isDevEnv ? LogEventLevel.Verbose : LogEventLevel.Information;
serviceCollection.AddSerilog();
serviceCollection.AddDbContextFactory<LogDbContext>((_, options) =>
{
options.UseSqlite($"Data Source={dbPath}");
});
/*
"LDK": "Trace",
"LDK.lightning::ln::peer_handler": "Debug",
"LDK.lightning::routing::gossip": "Information",
"LDK.BTCPayApp.Core.LDK.LDKPeerHandler": "Information",
"LDK.lightning_background_processor": "Information"*/
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.WriteTo.SQLite(dbPath)
.WriteTo.Console(outputTemplate: OutputTemplate, restrictedToMinimumLevel: minLogLevel)
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("System", LogEventLevel.Warning).CreateLogger();
}
}

View File

@ -4,7 +4,7 @@ public class Outbox
{
public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.Now;
public OutboxAction ActionType { get; set; }
public required string Key { get; set; }
public required string Entity { get; set; }
public required long Version { get; set; }
}
public string Key { get; set; }
public string Entity { get; set; }
public long Version { get; set; }
}

View File

@ -1,11 +1,8 @@
using System.Text.Json.Serialization;
namespace BTCPayApp.Core.Data;
namespace BTCPayApp.Core.Data;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum OutboxAction
{
Insert,
Update,
Delete
}
}

View File

@ -1,14 +1,8 @@
using System.Net;
using System.Text.Json.Serialization;
using BTCPayApp.Core.JsonConverters;
namespace BTCPayApp.Core.Data;
namespace BTCPayApp.Core.Data;
public record PeerInfo
{
[JsonConverter(typeof(EndPointJsonConverter))]
public EndPoint? Endpoint { get; set; }
public string Endpoint { get; set; }
public bool Persistent { get; set; }
public bool Trusted { get; set; }
public string? Label { get; set; }
}

View File

@ -0,0 +1,30 @@
using System.Text.Json;
using BTCPayApp.Core.Attempt2;
using Microsoft.EntityFrameworkCore;
using VSSProto;
namespace BTCPayApp.Core.Data;
class TriggerRecord
{
public string name { get; set; }
public string sql { get; set; }
}
public class RemoteToLocalSyncService
{
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly BTCPayConnectionManager _btcPayConnectionManager;
public RemoteToLocalSyncService(IDbContextFactory<AppDbContext> dbContextFactory,
BTCPayConnectionManager btcPayConnectionManager)
{
_dbContextFactory = dbContextFactory;
_btcPayConnectionManager = btcPayConnectionManager;
}
// on connected to btcpay, sync all the data from the remote to the local
// if we are the active node
}

View File

@ -5,8 +5,8 @@ namespace BTCPayApp.Core.Data;
public class Setting:VersionedData
{
[Key]
public required string Key { get; set; }
public byte[]? Value { get; set; }
public string Key { get; set; }
public byte[] Value { get; set; }
public bool Backup { get; set; } = true;
public override string EntityKey

View File

@ -1,7 +0,0 @@
namespace BTCPayApp.Core.Data;
class TriggerRecord
{
public string name { get; set; }
public string sql { get; set; }
}

View File

@ -1,6 +1,4 @@
using System.Text.Json.Serialization;
using BTCPayApp.Core.JsonConverters;
using NBitcoin;
using NBitcoin;
namespace BTCPayApp.Core.Data;
@ -10,40 +8,14 @@ public class WalletConfig
public required string Mnemonic { get; set; }
public required string Network { get; set; }
//key is the identifier of the tracker, value is a sub wallet format.
//key is the identifier of the tracker, value is a sub wallet format.
//for example, we will track native segwit wallet, the descriptor will be wpkh([fingerprint/84'/0'/0']xpub/0/*)
// or for LN specifics, the descriptor is null, and we track non deterministic scripts
public Dictionary<string, WalletDerivation> Derivations { get; set; } = new();
[JsonIgnore]
public string Fingerprint => new Mnemonic(Mnemonic).DeriveExtKey().GetPublicKey().GetHDFingerPrint().ToString();
[JsonIgnore]
public Network? NBitcoinNetwork => NBitcoin.Network.GetNetwork(Network);
public required BlockSnapshot Birthday { get; set; }
public required CoinSnapshot CoinSnapshot { get; set; }
}
public class CoinSnapshot
{
public required BlockSnapshot BlockSnapshot { get; set; }
public required Dictionary<string, SavedCoin[]> Coins { get; set; }
}
public class SavedCoin
{
[JsonConverter(typeof(BitcoinSerializableJsonConverterFactory))]
public required OutPoint Outpoint { get; set; }
[JsonConverter(typeof(KeyPathJsonConverter))]
public KeyPath? Path { get; set; }
}
public class BlockSnapshot
{
public required uint BlockHeight { get; set; }
[JsonConverter(typeof(UInt256JsonConverter))]
public required uint256 BlockHash { get; set; }
}
}

View File

@ -1,15 +1,12 @@
namespace BTCPayApp.Core.Data;
namespace BTCPayApp.Core.Data;
public class WalletDerivation
{
public string Identifier { get; set; }
public string Name { get; set; }
public string? Descriptor { get; set; }
public const string NativeSegwit = "segwit";
public const string LightningScripts = "lightningScripts";
// public const string SpendableOutputs = "spendableOutputs";
public required string Name { get; set; }
public string? Identifier { get; set; }
public string? Descriptor { get; set; }
// TODO: this is useful when restoring, to tell NBX to generate addresses up to this to prevent address reuse.
public int? LastKnownIndex{ get; set; }
}
}

View File

@ -20,7 +20,7 @@
// public VSSMapperInterceptor(BTCPayConnectionManager btcPayConnectionManager, ILogger<VSSMapperInterceptor> logger)
// {
// }
//
//
// private ConcurrentDictionary<EventId, object> PendingEvents = new ConcurrentDictionary<EventId, object>();
// public override ValueTask<int> SavedChangesAsync(SaveChangesCompletedEventData eventData, int result,
// CancellationToken cancellationToken = new CancellationToken())
@ -39,12 +39,12 @@
// {
// if (entry.State == EntityState.Deleted)
// {
//
//
// api.DeleteObjectAsync(new DeleteObjectRequest
// {
// KeyValue = new KeyValue()
// {
//
//
// }
// Key = $"LightningPayment/{lightningPayment.Id}"
// });
@ -52,14 +52,14 @@
// }
// if (entry.Entity is Channel channel)
// {
//
//
// }
// if (entry.Entity is Setting setting)
// {
//
//
// }
// }
//
//
// return base.SavingChangesAsync(eventData, result, cancellationToken);
// }
//
@ -76,11 +76,12 @@
// PendingEvents.Remove(eventData.EventId, out _);
// return base.SaveChangesFailedAsync(eventData, cancellationToken);
// }
//
//
//
//
// }
//
using System.Text.Json;
using AsyncKeyedLock;
using BTCPayApp.Core.Contracts;
@ -88,29 +89,32 @@ using BTCPayApp.Core.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace BTCPayApp.Core;
public class DatabaseConfigProvider(
IDbContextFactory<AppDbContext> dbContextFactory,
ILogger<DatabaseConfigProvider> logger)
: ConfigProvider
public class DatabaseConfigProvider: IConfigProvider
{
private readonly AsyncKeyedLocker<string> _lock = new();
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly ILogger<DatabaseConfigProvider> _logger;
private AsyncKeyedLocker<string> _lock = new();
public override async Task<T?> Get<T>(string key) where T : default
public DatabaseConfigProvider(IDbContextFactory<AppDbContext> dbContextFactory, ILogger<DatabaseConfigProvider> logger)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync();
_dbContextFactory = dbContextFactory;
_logger = logger;
}
public async Task<T?> Get<T>(string key)
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var config = await dbContext.Settings.FindAsync(key);
if (typeof(T) == typeof(byte[]))
return (T?) (config?.Value as object);
return config is null ? default : JsonSerializer.Deserialize<T>(config.Value);
}
public override async Task Set<T>(string key, T? value, bool backup) where T : default
public async Task Set<T>(string key, T? value, bool backup)
{
using var releaser = await _lock.LockAsync(key);
logger.LogDebug("Setting {Key} to {Value} {Backup}", key, value, backup ? "backup": "no backup");
await using var dbContext = await dbContextFactory.CreateDbContextAsync();
_logger.LogDebug("Setting {key} to {value} {backup}", key, value, backup? "backup": "no backup");
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
if (value is null)
{
try
@ -127,11 +131,12 @@ public class DatabaseConfigProvider(
var newValue = typeof(T) == typeof(byte[])? value as byte[]:JsonSerializer.SerializeToUtf8Bytes(value);
var setting = new Setting {Key = key, Value = newValue, Backup = backup};
await dbContext.Upsert(setting, CancellationToken.None);
}
public override async Task<IEnumerable<string>> List(string prefix)
public async Task<IEnumerable<string>> List(string prefix)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync();
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Settings.Where(s => s.Key.StartsWith(prefix)).Select(s => s.Key).ToListAsync();
}
}

View File

@ -1,76 +0,0 @@
#if DEBUG
using System.Net.Security;
using System.Net.WebSockets;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace BTCPayApp.Core.Extensions;
public class DangerousHttpClientFactory : IHttpClientFactory
{
public static bool ServerValidate(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors errors)
{
if (errors == SslPolicyErrors.None) return true;
return certificate?.Subject.Equals("CN=localhost") is true;
}
private static HttpClientHandler GetInsecureHandler()
{
var handler = new HttpClientHandler();
handler.ServerCertificateCustomValidationCallback = ServerValidate;
return handler;
}
public HttpClient CreateClient(string name)
{
return new HttpClient(GetInsecureHandler());
}
}
#if ANDROID
public class DangerousAndroidMessageHandler : Xamarin.Android.Net.AndroidMessageHandler
{
protected override Javax.Net.Ssl.IHostnameVerifier GetSSLHostnameVerifier(Javax.Net.Ssl.HttpsURLConnection connection)
=> new CustomHostnameVerifier();
private sealed class CustomHostnameVerifier : Java.Lang.Object, Javax.Net.Ssl.IHostnameVerifier
{
public bool Verify(string? hostname, Javax.Net.Ssl.ISSLSession? session)
{
return session?.PeerPrincipal?.Name == "CN=localhost";
}
}
}
#endif
public static class DebugExtensions
{
public static IServiceCollection AddDangerousSSLSettingsForDev(this IServiceCollection services)
{
services.Replace(ServiceDescriptor.Singleton<IHttpClientFactory, DangerousHttpClientFactory>());
services.AddSingleton<Func<HttpMessageHandler, HttpMessageHandler>>(handler =>
{
if (handler is HttpClientHandler clientHandler)
{
// always verify the SSL certificate
clientHandler.ServerCertificateCustomValidationCallback += DangerousHttpClientFactory.ServerValidate;
return clientHandler;
}
#if ANDROID
return new DangerousAndroidMessageHandler();
#else
return handler;
#endif
});
services.AddSingleton<Action<ClientWebSocketOptions>>(provider => wsc =>
{
wsc.RemoteCertificateValidationCallback = DangerousHttpClientFactory.ServerValidate;
});
return services;
}
}
#endif

View File

@ -6,24 +6,16 @@ namespace BTCPayApp.Core.Helpers;
public static class AsyncExtensions
{
public static async Task RunInOtherThread(Action action)
public static async Task RunSync(this Task task)
{
await Task.Factory.StartNew(action);
task.GetAwaiter().GetResult();
}
public static async Task<T> RunInOtherThread<T>(Func<T> action)
public static async Task<T> RunSync<T>(this Task<T> task)
{
return await Task.Factory.StartNew(action);
}
public static async Task RunInOtherThread(this Task task)
{
await Task.Factory.StartNew(async () => await task).Unwrap();
}
public static async Task<T> RunInOtherThread<T>(this Task<T> task)
{
return await Task.Factory.StartNew(async () => await task).Unwrap();
return task.GetAwaiter().GetResult();
}
/// <summary>
/// Allows a cancellation token to be awaited.
@ -54,7 +46,7 @@ public static class AsyncExtensions
public object GetResult()
{
// this is called by compiler generated methods when the
// task has completed. Instead of returning a result, we
// task has completed. Instead of returning a result, we
// just throw an exception.
if (IsCompleted) throw new OperationCanceledException();
else throw new InvalidOperationException("The cancellation token has not yet been cancelled.");
@ -72,4 +64,4 @@ public static class AsyncExtensions
public void UnsafeOnCompleted(Action continuation) =>
CancellationToken.Register(continuation);
}
}
}

View File

@ -1,36 +0,0 @@
using BTCPayApp.Core.Auth;
using BTCPayServer.Client;
using Microsoft.AspNetCore.Authorization;
namespace BTCPayApp.Core.Helpers;
// Copied from BTCPayServer
public static class AuthorizationOptionsExtensions
{
public static AuthorizationOptions AddPolicies(this AuthorizationOptions options)
{
// BTCPay policies
foreach (var p in Policies.AllPolicies)
{
options.AddPolicy(p);
}
options.AddPolicy(Policies.CanModifyStoreSettingsUnscoped);
options.AddPolicy(CanGetRates.Key);
// app policies
foreach (var p in AppPolicies.AllPolicies)
{
options.AddPolicy(p);
}
return options;
}
private static void AddPolicy(this AuthorizationOptions options, string policy)
{
options.AddPolicy(policy, o => o.AddRequirements(new PolicyRequirement(policy)));
}
private class CanGetRates
{
public const string Key = "btcpay.store.cangetrates";
}
}

View File

@ -1,42 +1,38 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace BTCPayApp.Core.Helpers;
public abstract class BaseHostedService(ILogger logger) : IHostedService, IDisposable
public abstract class BaseHostedService : IHostedService, IDisposable
{
protected CancellationTokenSource CancellationTokenSource = new();
protected readonly SemaphoreSlim ControlSemaphore = new(1, 1);
protected CancellationTokenSource _cancellationTokenSource = new();
protected readonly SemaphoreSlim _controlSemaphore = new(1, 1);
private Task? _currentTask;
public async Task StartAsync(CancellationToken cancellationToken)
{
await ControlSemaphore.WaitAsync(cancellationToken);
await _controlSemaphore.WaitAsync(cancellationToken);
try
{
CancellationTokenSource = new CancellationTokenSource();
await ExecuteStartAsync(CancellationTokenSource.CreateLinkedTokenSource(CancellationTokenSource.Token, cancellationToken).Token);
_cancellationTokenSource = new CancellationTokenSource();
await ExecuteStartAsync(CancellationTokenSource.CreateLinkedTokenSource(_cancellationTokenSource.Token, cancellationToken).Token);
}
finally
{
ControlSemaphore.Release();
_controlSemaphore.Release();
}
}
public async Task StopAsync(CancellationToken cancellationToken)
{
logger.LogInformation("Stopping service");
await CancellationTokenSource.CancelAsync();
await ControlSemaphore.WaitAsync(cancellationToken);
await _cancellationTokenSource.CancelAsync();
await _controlSemaphore.WaitAsync(cancellationToken);
try
{
await ExecuteStopAsync(CancellationTokenSource.Token);
logger.LogInformation("Stopped");
await ExecuteStopAsync(_cancellationTokenSource.Token);
}
finally
{
ControlSemaphore.Release();
_controlSemaphore.Release();
}
}
@ -45,20 +41,7 @@ public abstract class BaseHostedService(ILogger logger) : IHostedService, IDispo
public virtual void Dispose()
{
CancellationTokenSource?.Dispose();
ControlSemaphore?.Dispose();
_cancellationTokenSource?.Dispose();
_controlSemaphore?.Dispose();
}
protected async Task WrapInLock(Func<Task> act, CancellationToken cancellationToken)
{
await ControlSemaphore.WaitAsync(cancellationToken);
try
{
await act();
}
finally
{
ControlSemaphore.Release();
}
}
}
}

View File

@ -9,10 +9,10 @@ namespace BTCPayApp.Core.Helpers;
public static class ChannelExtensions
{
public static IDisposable SubscribeToEventWithChannelQueue<TEvent>(
Action<AsyncEventHandler<TEvent>> add,
Action<AsyncEventHandler<TEvent>> remove,
Action<AsyncEventHandler<TEvent>> remove,
Func<TEvent, CancellationToken, Task> processor,
CancellationToken cancellationToken)
{
@ -26,42 +26,44 @@ public static class ChannelExtensions
add(OnEvent);
_ = channel.ProcessChannel(processor, cancellationToken);
return new DisposableWrapper(() =>
return new DisposableWrapper(async () =>
{
remove(OnEvent);
channel.Writer.Complete();
return Task.CompletedTask;
});
}
private static async Task ProcessChannel<TEvent>(this Channel<TEvent> channel, Func<TEvent, CancellationToken, Task> processor, CancellationToken cancellationToken)
public static async Task ProcessChannel<TEvent>(this Channel<TEvent> channel, Func<TEvent, CancellationToken, Task> processor, CancellationToken cancellationToken)
{
while (await channel.Reader.WaitToReadAsync(cancellationToken))
{
while (channel.Reader.TryRead(out var item))
while (channel.Reader.TryRead(out TEvent item))
{
await processor(item, cancellationToken);
}
}
}
public static (BitcoinExtPubKey, RootedKeyPath?, ScriptPubKeyType)? ExtractFromDescriptor(this string descriptor, Network network)
public static (BitcoinExtPubKey, RootedKeyPath, ScriptPubKeyType)? ExtractFromDescriptor(this string descriptor, Network network)
{
var od = OutputDescriptor.Parse(descriptor, network);
(BitcoinExtPubKey, RootedKeyPath?) ExtractFromPkProvider(PubKeyProvider pubKeyProvider)
(BitcoinExtPubKey, RootedKeyPath) ExtractFromPkProvider(PubKeyProvider pubKeyProvider)
{
switch (pubKeyProvider)
{
case PubKeyProvider.Const _:
throw new FormatException("Only HD output descriptors are supported.");
case PubKeyProvider.HD hd:
if (hd.Path is not null && hd.Path.ToString() != "0")
if (hd.Path != null && hd.Path.ToString() != "0")
{
throw new FormatException("Custom change paths are not supported.");
}
return (hd.Extkey, null);
case PubKeyProvider.Origin origin:
var innerResult = ExtractFromPkProvider(origin.Inner);
return (innerResult.Item1, origin.KeyOriginInfo);
return (innerResult.Item1, origin.KeyOriginInfo );
default:
throw new ArgumentOutOfRangeException();
}
@ -79,25 +81,20 @@ public static class ChannelExtensions
public static UserConfig AsLDKUserConfig(this LightningConfig config)
{
var result = UserConfig.with_default();
// var channelConfig = ChannelConfig.with_default();
// channelConfig.set
// result.set_channel_config(channelConfig);
result.set_accept_intercept_htlcs(true);
//result.set_accept_mpp_keysend(true);
result.set_accept_mpp_keysend(true);
result.set_manually_accept_inbound_channels(true);
var channelHandshakeConfig = ChannelHandshakeConfig.with_default();
//channelHandshakeConfig.set_announced_channel(false);
// channelHandshakeConfig.set(false);
channelHandshakeConfig.set_announced_channel(false);
channelHandshakeConfig.set_negotiate_anchors_zero_fee_htlc_tx(true);
channelHandshakeConfig.set_minimum_depth(1);
result.set_channel_handshake_config(channelHandshakeConfig);
var channelHandshakeLimits = ChannelHandshakeLimits.with_default();
channelHandshakeLimits.set_force_announced_channel_preference(true);
channelHandshakeLimits.set_max_funding_satoshis(Money.Coins(100m).Satoshi);
result.set_channel_handshake_limits(channelHandshakeLimits);
return result;
}
// public static async Task Process<T>(this Channel<T> channel, Func<T, CancellationToken, Task> processor,
// CancellationToken cancellationToken)
// {
@ -175,4 +172,4 @@ public static class ChannelExtensions
}
}
}
}
}

View File

@ -31,16 +31,17 @@ public static class ChannelManagerHelper
}).ToArray();
}
public static ChannelManager? Load(ChannelMonitor[] channelMonitors, byte[] channelManagerSerialized,
EntropySource entropySource, SignerProvider signerProvider,
NodeSigner nodeSigner, FeeEstimator feeEstimator,
Watch watch, BroadcasterInterface txBroadcaster,
Router router, MessageRouter messageRouter, Logger logger, UserConfig config, Filter filter)
Router router, Logger logger, UserConfig config, Filter filter)
{
var resManager = UtilMethods.C2Tuple_ThirtyTwoBytesChannelManagerZ_read(channelManagerSerialized, entropySource,
nodeSigner, signerProvider, feeEstimator,
watch, txBroadcaster,
router, messageRouter, logger, config, channelMonitors);
router, logger, config, channelMonitors);
if (!resManager.is_ok())
{
throw new SerializationException("Serialized ChannelManager was corrupt");
@ -54,4 +55,4 @@ public static class ChannelManagerHelper
return (resManager as Result_C2Tuple_ThirtyTwoBytesChannelManagerZDecodeErrorZ.
Result_C2Tuple_ThirtyTwoBytesChannelManagerZDecodeErrorZ_OK)?.res.get_b();
}
}
}

View File

@ -1,27 +0,0 @@
using BTCPayApp.Core.Contracts;
using NBitcoin;
namespace BTCPayApp.Core.Helpers;
public static class ConfigExtensions
{
private const string ConfigDeviceIdentifierKey = "deviceIdentifier";
/*
public static async Task<long> GetDeviceIdentifier(this ISecureConfigProvider configProvider)
{
var id = await configProvider.Get<long>(ConfigDeviceIdentifierKey);
if (id == 0)
{
id = RandomUtils.GetInt64();
await configProvider.Set(ConfigDeviceIdentifierKey, id);
}
return id;
}
*/
public static async Task<long> GetDeviceIdentifier(this ConfigProvider configProvider)
{
return await configProvider.GetOrSet(ConfigDeviceIdentifierKey, () => Task.FromResult(RandomUtils.GetInt64()), false);
}
}

View File

@ -1,24 +0,0 @@
using BTCPayApp.Core.Contracts;
namespace BTCPayApp.Core.Helpers;
public static class ConfigHelpers
{
public static async Task<T?> GetOrSet<T>(this ISecureConfigProvider secureConfigProvider, string key, Func<Task<T>> factory)
{
var value = await secureConfigProvider.Get<T>(key);
if (!Equals(value, default(T))) return value;
value = await factory();
await secureConfigProvider.Set(key, value);
return value;
}
public static async Task<T?> GetOrSet<T>(this ConfigProvider configProvider, string key, Func<Task<T>> factory, bool backup)
{
var value = await configProvider.Get<T>(key);
if (!Equals(value, default(T))) return value;
value = await factory();
await configProvider.Set(key, value, backup);
return value;
}
}

View File

@ -2,27 +2,30 @@ using System.Diagnostics.CodeAnalysis;
using System.Net;
namespace BTCPayApp.Core.Helpers;
//from wasabi
//from wasabi
public static class EndPointParser
{
public static IPEndPoint IPEndPoint(this EndPoint endPoint)
{
if(endPoint is IPEndPoint ipEndPoint)
return ipEndPoint;
if(endPoint is not DnsEndPoint dnsEndPoint)
throw new FormatException($"Invalid endpoint: {endPoint}");
var addresses = Dns.GetHostAddresses(dnsEndPoint.Host);
var addresses = System.Net.Dns.GetHostAddresses(dnsEndPoint.Host);
if (addresses.Length == 0)
{
throw new ArgumentException(
"Unable to retrieve address from specified host name.",
"Unable to retrieve address from specified host name.",
"hostName"
);
}
return new IPEndPoint(addresses[0], dnsEndPoint.Port); // Port gets validated here.
}
public static string Host(this EndPoint me)
{
if (me is DnsEndPoint dnsEndPoint)
@ -38,7 +41,7 @@ public static class EndPointParser
throw new FormatException($"Invalid endpoint: {me}");
}
}
public static int? Port(this EndPoint me)
{
var result = 0;
@ -60,7 +63,7 @@ public static class EndPointParser
}
return result;
}
public static string ToString(this EndPoint me, int defaultPort)
{
string host = me.Host();
@ -70,9 +73,11 @@ public static class EndPointParser
return endPointString;
}
/// <param name="defaultPort">If invalid and it's needed to use, then this function returns false.</param>
public static bool TryParse(string? endPointString, int defaultPort, [NotNullWhen(true)] out EndPoint? endPoint)
{
if (!string.IsNullOrEmpty(endPointString) && System.Net.IPEndPoint.TryParse(endPointString, out var ipEndPoint))
if(System.Net.IPEndPoint.TryParse(endPointString, out var ipEndPoint))
{
if (ipEndPoint.Port == 0)
{
@ -81,12 +86,15 @@ public static class EndPointParser
endPoint = ipEndPoint;
return true;
}
endPoint = null;
try
{
if (string.IsNullOrWhiteSpace(endPointString)) return false;
if (string.IsNullOrWhiteSpace(endPointString))
{
return false;
}
endPointString = endPointString.TrimEnd(':', '/');

View File

@ -1,6 +1,6 @@
namespace BTCPayApp.Core.Helpers;
public delegate Task AsyncEventHandler<in TEventArgs>(object? sender, TEventArgs e);
public delegate Task AsyncEventHandler<TEventArgs>(object? sender, TEventArgs e);
public delegate Task AsyncEventHandler(object? sender);
public static class EventHandlers
@ -24,12 +24,12 @@ public static class EventHandlers
return Task.CompletedTask;
});
private static EventHandler<TArgs> TryAsync<TArgs>(
public static EventHandler<TArgs> TryAsync<TArgs>(
this Func<object, TArgs, Task> callback,
Func<Exception, Task> errorHandler)
where TArgs : EventArgs
{
return new EventHandler<TArgs>(async void (s, e) =>
return new EventHandler<TArgs>(async (object s, TArgs e) =>
{
try
{
@ -41,4 +41,4 @@ public static class EventHandlers
}
});
}
}
}

View File

@ -11,9 +11,9 @@ namespace BTCPayApp.Core.Helpers;
/// </summary>
/// <typeparam name="TKey">The type of the keys in the dictionary.</typeparam>
/// <typeparam name="TValue">The type of the values in the dictionary.</typeparam>
public sealed class ObservableConcurrentDictionary<TKey, TValue> : ConcurrentDictionary<TKey, TValue>, INotifyCollectionChanged, INotifyPropertyChanged where TKey : notnull
public sealed class ObservableConcurrentDictionary<TKey, TValue> : ConcurrentDictionary<TKey, TValue>, INotifyCollectionChanged, INotifyPropertyChanged
{
private const string? IndexerName = "Item[]";
private const string IndexerName = "Item[]";
/// <summary>
/// Initializes a new instance of the <see cref="ObservableConcurrentDictionary{TKey, TValue}"/> class that is empty, has the
@ -109,10 +109,10 @@ public sealed class ObservableConcurrentDictionary<TKey, TValue> : ConcurrentDic
}
/// <summary>Occurs when an item is added, removed, changed, moved, or the entire list is refreshed.</summary>
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public event NotifyCollectionChangedEventHandler CollectionChanged;
/// <summary>Occurs when a property value changes.</summary>
public event PropertyChangedEventHandler? PropertyChanged;
public event PropertyChangedEventHandler PropertyChanged;
/// <summary>
/// Uses the specified functions to add a key/value pair to the <see cref="ObservableConcurrentDictionary{TKey, TValue}"/> if the
@ -275,7 +275,7 @@ public sealed class ObservableConcurrentDictionary<TKey, TValue> : ConcurrentDic
/// the default value of the <typeparamref name="TValue"/> type if <paramref name="key"/> does not exist.
/// </param>
/// <returns><see langword="true"/> if the object was removed successfully; otherwise, <see langword="false"/>.</returns>
public new bool TryRemove(TKey key, out TValue? value)
public new bool TryRemove(TKey key, out TValue value)
{
if (base.TryRemove(key, out value))
{
@ -319,5 +319,5 @@ public sealed class ObservableConcurrentDictionary<TKey, TValue> : ConcurrentDic
/// <summary>Raises the <see cref="PropertyChanged" /> event.</summary>
/// <param name="propertyName">Name of the property that has changed.</param>
private void OnPropertyChanged([CallerMemberName] string? propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private void OnPropertyChanged([CallerMemberName] string propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

View File

@ -1,14 +0,0 @@
using Microsoft.AspNetCore.Authorization;
namespace BTCPayApp.Core.Helpers;
// Copied from BTCPayServer
public class PolicyRequirement : IAuthorizationRequirement
{
public PolicyRequirement(string policy)
{
ArgumentNullException.ThrowIfNull(policy);
Policy = policy;
}
public string Policy { get; }
}

View File

@ -1,99 +0,0 @@
using System.Text.Json;
using BTCPayApp.Core.Auth;
using BTCPayApp.Core.Data;
using BTCPayApp.Core.LDK;
using BTCPayApp.Core.Wallet;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Lightning;
namespace BTCPayApp.Core.Helpers;
public static class StoreHelpers
{
public static async Task<(GenericPaymentMethodData? onchain, GenericPaymentMethodData? lightning)>
GetCurrentStorePaymentMethods(this IAccountManager accountManager)
{
var storeId = accountManager.CurrentStore?.Id;
var pms = await accountManager.GetClient().GetStorePaymentMethods(storeId, includeConfig: true);
var onchain = pms.FirstOrDefault(pm => pm.PaymentMethodId == OnChainWalletManager.PaymentMethodId);
var lightning = pms.FirstOrDefault(pm => pm.PaymentMethodId == LightningNodeManager.PaymentMethodId);
return (onchain, lightning);
}
public static async Task<(GenericPaymentMethodData? onchain, GenericPaymentMethodData? lightning)?> TryApplyingAppPaymentMethodsToCurrentStore(
this IAccountManager accountManager,
OnChainWalletManager onChainWalletManager, LightningNodeManager lightningNodeService, bool applyOnchain, bool applyLighting)
{
var storeId = accountManager.CurrentStore?.Id;
var userId = accountManager.UserInfo?.UserId;
var config = await onChainWalletManager.GetConfig();
if (// are user and store present?
string.IsNullOrEmpty(userId) ||
string.IsNullOrEmpty(storeId) ||
// is user permitted? (store owner)
!await accountManager.IsAuthorized(Policies.CanModifyStoreSettings, storeId) ||
// is the onchain wallet configured?
!OnChainWalletManager.IsConfigured(config)) return null;
// check the store's payment methods
var (onchain, lightning) = await GetCurrentStorePaymentMethods(accountManager);
// onchain
if (applyOnchain && config?.Derivations.TryGetValue(WalletDerivation.NativeSegwit, out var derivation) is true && onchain is null)
{
onchain = await accountManager.GetClient().UpdateStorePaymentMethod(storeId, OnChainWalletManager.PaymentMethodId, new UpdatePaymentMethodRequest
{
Enabled = true,
Config = derivation.Descriptor
});
}
// lightning
if (applyLighting && lightning is null && lightningNodeService is { IsActive: true, Node.ApiKeyManager: { } apiKeyManager })
{
var key = await apiKeyManager.GetKeyForStore(storeId, APIKeyPermission.Write);
lightning = await accountManager.GetClient().UpdateStorePaymentMethod(storeId,
LightningNodeManager.PaymentMethodId, new UpdatePaymentMethodRequest
{
Enabled = true,
Config = key.ConnectionString(userId)
});
}
return (onchain, lightning);
}
public static async Task<bool> IsOnChainOurs(this OnChainWalletManager onChainWalletManager, GenericPaymentMethodData? onchain)
{
if (!string.IsNullOrEmpty(onchain?.Config.ToString()))
{
var config = await onChainWalletManager.GetConfig();
using var jsonDoc = JsonDocument.Parse(onchain.Config.ToString());
if (jsonDoc.RootElement.TryGetProperty("accountDerivation", out var derivationSchemeElement) &&
derivationSchemeElement.GetString() is { } derivationScheme &&
config?.Derivations.Any(pair => pair.Value.Identifier == $"DERIVATIONSCHEME:{derivationScheme}") is true)
return true;
}
return false;
}
public static async Task<bool> IsLightningOurs(this LightningNodeManager lightningNodeManager, GenericPaymentMethodData? lightning)
{
if (!string.IsNullOrEmpty(lightning?.Config.ToString()))
{
var node = lightningNodeManager.Node;
var apiKeyManager = node?.ApiKeyManager;
if (apiKeyManager == null) return false;
using var jsonDoc = JsonDocument.Parse(lightning.Config.ToString());
if (jsonDoc.RootElement.TryGetProperty("connectionString", out var connectionStringElement) &&
connectionStringElement.GetString() is { } connectionString &&
LightningConnectionStringHelper.ExtractValues(connectionString, out var lnConnectionString) is { } lnValues &&
lnConnectionString == "app" && lnValues.TryGetValue("key", out var key) && key is not null &&
await node!.ApiKeyManager.CheckPermission(key, APIKeyPermission.Read))
return true;
}
return false;
}
}

View File

@ -1,81 +0,0 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace BTCPayApp.Core.Helpers
{
/// <summary>
/// from https://github.com/dotnet/runtime/issues/31433#issuecomment-2148885279
/// </summary>
public static class SystemTextJsonMergeExtensions
{
/// <summary>
/// Merges the specified Json Node into the base JsonNode for which this method is called.
/// It is null safe and can be easily used with null-check & null coalesce operators for fluent calls.
/// NOTE: JsonNodes are context aware and track their parent relationships therefore to merge the values both JsonNode objects
/// specified are mutated. The Base is mutated with new data while the source is mutated to remove reverences to all
/// fields so that they can be added to the base.
///
/// Source taken directly from the open-source Gist here:
/// https://gist.github.com/cajuncoding/bf78bdcf790782090d231590cbc2438f
///
/// </summary>
/// <param name="jsonBase"></param>
/// <param name="jsonMerge"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
public static JsonNode Merge(this JsonNode jsonBase, JsonNode? jsonMerge)
{
if (jsonMerge == null)
return jsonBase;
switch (jsonBase)
{
case JsonObject jsonBaseObj when jsonMerge is JsonObject jsonMergeObj:
{
//NOTE: We must materialize the set (e.g. to an Array), and then clear the merge array so the node can then be
// re-assigned to the target/base Json; clearing the Object seems to be the most efficient approach...
var mergeNodesArray = jsonMergeObj.ToArray();
jsonMergeObj.Clear();
foreach (var prop in mergeNodesArray)
{
jsonBaseObj[prop.Key] = jsonBaseObj[prop.Key] switch
{
JsonObject jsonBaseChildObj when prop.Value is JsonObject jsonMergeChildObj => jsonBaseChildObj.Merge(jsonMergeChildObj),
JsonArray jsonBaseChildArray when prop.Value is JsonArray jsonMergeChildArray => jsonBaseChildArray.Merge(jsonMergeChildArray),
_ => prop.Value
};
}
break;
}
case JsonArray jsonBaseArray when jsonMerge is JsonArray jsonMergeArray:
{
//NOTE: We must materialize the set (e.g. to an Array), and then clear the merge array,
// so they can then be re-assigned to the target/base Json...
var mergeNodesArray = jsonMergeArray.ToArray();
jsonMergeArray.Clear();
foreach(var mergeNode in mergeNodesArray) jsonBaseArray.Add(mergeNode);
break;
}
default:
throw new ArgumentException($"The JsonNode type [{jsonBase.GetType().Name}] is incompatible for merging with the target/base " +
$"type [{jsonMerge.GetType().Name}]; merge requires the types to be the same.");
}
return jsonBase;
}
/// <summary>
/// Merges the specified Dictionary of values into the base JsonNode for which this method is called.
/// </summary>
/// <typeparam name="TKey"></typeparam>
/// <typeparam name="TValue"></typeparam>
/// <param name="jsonBase"></param>
/// <param name="dictionary"></param>
/// <param name="options"></param>
/// <returns></returns>
public static JsonNode MergeDictionary<TKey, TValue>(this JsonNode jsonBase, IDictionary<TKey, TValue> dictionary, JsonSerializerOptions? options = null)
=> jsonBase.Merge(JsonSerializer.SerializeToNode(dictionary, options));
}
}

View File

@ -1,11 +0,0 @@
using BTCPayServer.Lightning;
namespace BTCPayApp.Core.JsonConverters;
public class BOLT11PaymentRequestJsonConverter : GenericStringJsonConverter<BOLT11PaymentRequest>
{
public override BOLT11PaymentRequest Create(string str)
{
return NetworkHelper.Try(network => BOLT11PaymentRequest.Parse(str, network));
}
}

View File

@ -1,23 +0,0 @@
using NBitcoin;
namespace BTCPayApp.Core.JsonConverters;
public class BitcoinSerializableJsonConverter<T> : GenericStringJsonConverter<T> where T : IBitcoinSerializable
{
public override T Create(string str)
{
var bytes = Convert.FromHexString(str);
var instance = Activator.CreateInstance<T>();
return NetworkHelper.Try(network =>
{
instance.ReadWrite(bytes, network);
return instance;
});
}
public override string? ToString(T? instance)
{
return Convert.ToHexString(instance.ToBytes()).ToLowerInvariant();
}
}

View File

@ -1,19 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using NBitcoin;
namespace BTCPayApp.Core.JsonConverters;
public class BitcoinSerializableJsonConverterFactory : JsonConverterFactory
{
public override bool CanConvert(Type typeToConvert)
{
return typeof(IBitcoinSerializable).IsAssignableFrom(typeToConvert);
}
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
var converterType = typeof(BitcoinSerializableJsonConverter<>).MakeGenericType(typeToConvert);
return (JsonConverter) Activator.CreateInstance(converterType)!;
}
}

View File

@ -1,27 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace BTCPayApp.Core.JsonConverters;
public class DateTimeToUnixTimeConverter : JsonConverter<DateTimeOffset>
{
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
switch (reader.TokenType)
{
case JsonTokenType.Null:
return default;
case JsonTokenType.Number:
return DateTimeOffset.FromUnixTimeSeconds(reader.GetInt64());
case JsonTokenType.String:
return DateTimeOffset.FromUnixTimeSeconds(long.Parse(reader.GetString()!));
}
throw new JsonException("Expected number or string with a unix timestamp value");
}
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
{
writer.WriteNumberValue(value.ToUnixTimeSeconds());
}
}

View File

@ -1,37 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace BTCPayApp.Core.JsonConverters;
public abstract class GenericStringJsonConverter<T> : JsonConverter<T>
{
public abstract T Create(string str);
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null) return default;
if (reader.TokenType != JsonTokenType.String ||
reader.GetString() is not { } str ||
string.IsNullOrEmpty(str))
throw new JsonException("Expected string");
return Create(str);
}
public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options)
{
if (value is null)
{
writer.WriteNullValue();
return;
}
writer.WriteStringValue(ToString(value));
}
public virtual string? ToString(T? value)
{
return value?.ToString() ?? string.Empty;
}
}

View File

@ -1,11 +0,0 @@
using NBitcoin;
namespace BTCPayApp.Core.JsonConverters;
public class KeyPathJsonConverter : GenericStringJsonConverter<KeyPath>
{
public override KeyPath Create(string str)
{
return new KeyPath(str);
}
}

View File

@ -1,11 +0,0 @@
using BTCPayServer.Lightning;
namespace BTCPayApp.Core.JsonConverters;
public class LightMoneyJsonConverter : GenericStringJsonConverter<LightMoney>
{
public override LightMoney Create(string str)
{
return LightMoney.Parse(str);
}
}

Some files were not shown because too many files have changed in this diff Show More