├── .github └── workflows │ ├── ci.yml │ └── main_v24.02.yml ├── .gitignore ├── COPYING ├── Cargo.toml ├── README.md ├── demo-script.md ├── lnd.py ├── platforms ├── Dockerfile ├── README.md ├── app.py ├── bash-script-rtl │ └── peerstables.sh ├── index.tsx ├── mnemonic-tool.py ├── python-cln-plugin │ ├── requirements.txt │ └── stablechannels.py ├── python-rest │ └── stablechannels.py ├── stablechannels.html ├── stablecoin-chart.html ├── sum_payments.py ├── update.sh └── utxoracle-cln-plugin │ └── utxoracle.py ├── requirements.txt ├── src ├── audit.rs ├── ldk_node_adapter.rs ├── lightning.rs ├── main.rs ├── price_feeds.rs ├── server.rs ├── stable.rs ├── types.rs └── user.rs ├── stablechannels.py └── test_stablechannels.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Cancel duplicate jobs 4 | concurrency: 5 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 6 | cancel-in-progress: true 7 | 8 | on: 9 | workflow_call: 10 | inputs: 11 | cln-version: 12 | required: true 13 | type: string 14 | pyln-version: 15 | required: true 16 | type: string 17 | tagged-release: 18 | required: true 19 | type: boolean 20 | 21 | jobs: 22 | build: 23 | name: Test CLN=${{ inputs.cln-version }}, OS=${{ matrix.os }}, PY=${{ matrix.python-version }}, BCD=${{ matrix.bitcoind-version }}, EXP=${{ matrix.experimental }}, DEP=${{ matrix.deprecated }} 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | bitcoind-version: ["26.1"] 28 | experimental: [1] 29 | deprecated: [0] 30 | python-version: ["3.8", "3.12"] 31 | os: ["ubuntu-latest"] 32 | 33 | runs-on: ${{ matrix.os }} 34 | 35 | steps: 36 | - name: Checkout code 37 | uses: actions/checkout@v4 38 | 39 | - name: Create cache paths 40 | run: | 41 | sudo mkdir /usr/local/libexec 42 | sudo mkdir /usr/local/libexec/c-lightning 43 | sudo mkdir /usr/local/libexec/c-lightning/plugins 44 | sudo chown -R $USER /usr/local/libexec 45 | 46 | - name: Cache CLN 47 | id: cache-cln 48 | uses: actions/cache@v4 49 | with: 50 | path: | 51 | /usr/local/bin/lightning* 52 | /usr/local/libexec/c-lightning 53 | key: cache-cln-${{ inputs.cln-version }}-${{ runner.os }} 54 | 55 | - name: Cache bitcoind 56 | id: cache-bitcoind 57 | uses: actions/cache@v4 58 | with: 59 | path: /usr/local/bin/bitcoin* 60 | key: cache-bitcoind-${{ matrix.bitcoind-version }}-${{ runner.os }} 61 | 62 | - name: Download Bitcoin ${{ matrix.bitcoind-version }} & install binaries 63 | if: ${{ steps.cache-bitcoind.outputs.cache-hit != 'true' }} 64 | run: | 65 | export BITCOIND_VERSION=${{ matrix.bitcoind-version }} 66 | if [[ "${{ matrix.os }}" =~ "ubuntu" ]]; then 67 | export TARGET_ARCH="x86_64-linux-gnu" 68 | fi 69 | if [[ "${{ matrix.os }}" =~ "macos" ]]; then 70 | export TARGET_ARCH="x86_64-apple-darwin" 71 | fi 72 | wget https://bitcoincore.org/bin/bitcoin-core-${BITCOIND_VERSION}/bitcoin-${BITCOIND_VERSION}-${TARGET_ARCH}.tar.gz 73 | tar -xzf bitcoin-${BITCOIND_VERSION}-${TARGET_ARCH}.tar.gz 74 | sudo mv bitcoin-${BITCOIND_VERSION}/bin/* /usr/local/bin 75 | rm -rf bitcoin-${BITCOIND_VERSION}-${TARGET_ARCH}.tar.gz bitcoin-${BITCOIND_VERSION} 76 | 77 | - name: Download Core Lightning ${{ inputs.cln-version }} & install binaries 78 | if: ${{ contains(matrix.os, 'ubuntu') && steps.cache-cln.outputs.cache-hit != 'true' }} 79 | run: | 80 | url=$(curl -s https://api.github.com/repos/ElementsProject/lightning/releases/tags/${{ inputs.cln-version }} \ 81 | | jq '.assets[] | select(.name | contains("22.04")) | .browser_download_url' \ 82 | | tr -d '\"') 83 | wget $url 84 | sudo tar -xvf ${url##*/} -C /usr/local --strip-components=2 85 | echo "CLN_VERSION=$(lightningd --version)" >> "$GITHUB_OUTPUT" 86 | 87 | - name: Set up Python ${{ matrix.python-version }} 88 | uses: actions/setup-python@v5 89 | with: 90 | python-version: ${{ matrix.python-version }} 91 | 92 | - name: Checkout Core Lightning ${{ inputs.cln-version }} 93 | if: ${{ contains(matrix.os, 'macos') && steps.cache-cln.outputs.cache-hit != 'true' }} 94 | uses: actions/checkout@v4 95 | with: 96 | repository: 'ElementsProject/lightning' 97 | path: 'lightning' 98 | ref: ${{ inputs.cln-version }} 99 | submodules: 'recursive' 100 | 101 | - name: Install Python and System dependencies 102 | run: | 103 | if [[ "${{ matrix.os }}" =~ "macos" ]]; then 104 | brew install autoconf automake libtool gnu-sed gettext libsodium sqlite 105 | fi 106 | python -m venv venv 107 | source venv/bin/activate 108 | python -m pip install -U pip poetry wheel 109 | pip3 install "pyln-proto<=${{ inputs.pyln-version }}" "pyln-client<=${{ inputs.pyln-version }}" "pyln-testing<=${{ inputs.pyln-version }}" 110 | pip3 install pytest-xdist pytest-test-groups pytest-timeout 111 | pip3 install -r requirements.txt 112 | 113 | - name: Compile Core Lightning ${{ inputs.cln-version }} & install binaries 114 | if: ${{ contains(matrix.os, 'macos') && steps.cache-cln.outputs.cache-hit != 'true' }} 115 | run: | 116 | export EXPERIMENTAL_FEATURES=${{ matrix.experimental }} 117 | export COMPAT=${{ matrix.deprecated }} 118 | export VALGRIND=0 119 | source venv/bin/activate 120 | 121 | cd lightning 122 | 123 | poetry lock 124 | poetry install 125 | ./configure --disable-valgrind 126 | poetry run make 127 | sudo make install 128 | 129 | - name: Run tests 130 | run: | 131 | export CLN_PATH=${{ github.workspace }}/lightning 132 | export COMPAT=${{ matrix.deprecated }} 133 | export EXPERIMENTAL_FEATURES=${{ matrix.experimental }} 134 | export SLOW_MACHINE=1 135 | export TEST_DEBUG=1 136 | export TRAVIS=1 137 | export VALGRIND=0 138 | export PYTEST_TIMEOUT=600 139 | source venv/bin/activate 140 | pytest -n=5 test_stablechannels.py 141 | -------------------------------------------------------------------------------- /.github/workflows/main_v24.02.yml: -------------------------------------------------------------------------------- 1 | name: main on CLN v24.02.2 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - 'Dockerfile' 9 | - '*.md' 10 | - 'LICENSE' 11 | - '.gitignore' 12 | - 'coffee.yml' 13 | - '*.sh' 14 | - 'lnd.py' 15 | pull_request: 16 | workflow_dispatch: 17 | 18 | jobs: 19 | call-ci: 20 | uses: ./.github/workflows/ci.yml 21 | with: 22 | cln-version: "v24.02.2" 23 | pyln-version: "24.02" 24 | tagged-release: false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .git 2 | .DS_Store 3 | 4 | gitignore 5 | Copy code 6 | 7 | # iOS / Swift 8 | ## User settings 9 | xcuserdata/ 10 | 11 | ## Obj-C/Swift specific 12 | *.hmap 13 | 14 | ## App packaging 15 | *.ipa 16 | *.dSYM.zip 17 | *.dSYM 18 | 19 | ## Playgrounds 20 | timeline.xctimeline 21 | playground.xcworkspace 22 | 23 | # Swift Package Manager 24 | .build/ 25 | 26 | # CocoaPods 27 | Pods/ 28 | 29 | # Carthage 30 | Carthage/Build/ 31 | 32 | # Accio dependency management 33 | Dependencies/ 34 | .accio/ 35 | 36 | # fastlane 37 | fastlane/report.xml 38 | fastlane/Preview.html 39 | fastlane/screenshots/**/*.png 40 | fastlane/test_output 41 | 42 | # Code Injection 43 | iOSInjectionProject/ 44 | 45 | # Rust 46 | /target/ 47 | /data/ 48 | Cargo.lock 49 | **/*.rs.bk 50 | 51 | # JavaScript / Node.js 52 | node_modules/ 53 | npm-debug.log 54 | yarn-error.log 55 | yarn-debug.log* 56 | .pnpm-debug.log* 57 | 58 | # Logs 59 | logs 60 | *.log 61 | 62 | # Runtime data 63 | pids 64 | *.pid 65 | *.seed 66 | *.pid.lock 67 | 68 | # Directory for instrumented libs generated by jscoverage/JSCover 69 | lib-cov 70 | 71 | # Coverage directory used by tools like istanbul 72 | coverage 73 | *.lcov 74 | 75 | # Dependency directories 76 | jspm_packages/ 77 | 78 | # TypeScript cache 79 | *.tsbuildinfo 80 | 81 | # Optional npm cache directory 82 | .npm 83 | 84 | # Optional eslint cache 85 | .eslintcache 86 | 87 | # Optional stylelint cache 88 | .stylelintcache 89 | 90 | # dotenv environment variable files 91 | .env 92 | .env.development.local 93 | .env.test.local 94 | .env.production.local 95 | .env.local 96 | 97 | # parcel-bundler cache 98 | .cache 99 | .parcel-cache 100 | 101 | # Next.js build output 102 | .next 103 | out 104 | 105 | # Nuxt.js build / generate output 106 | .nuxt 107 | dist 108 | 109 | # Gatsby files 110 | .cache/ 111 | 112 | # vuepress build output 113 | .vuepress/dist 114 | 115 | # Serverless directories 116 | .serverless/ 117 | 118 | # FuseBox cache 119 | .fusebox/ 120 | 121 | # DynamoDB Local files 122 | .dynamodb/ 123 | 124 | # TernJS port file 125 | .tern-port 126 | 127 | # Stores VSCode versions used for testing VSCode extensions 128 | .vscode-test 129 | 130 | # yarn v2 131 | .yarn/cache 132 | .yarn/unplugged 133 | .yarn/build-state.yml 134 | .yarn/install-state.gz 135 | .pnp.* 136 | 137 | # General 138 | .DS_Store 139 | .AppleDouble 140 | .LSOverride 141 | 142 | # Icon must end with two \r 143 | Icon 144 | 145 | # Thumbnails 146 | ._* 147 | 148 | # Files that might appear in the root of a volume 149 | .DocumentRevisions-V100 150 | .fseventsd 151 | .Spotlight-V100 152 | .TemporaryItems 153 | .Trashes 154 | .VolumeIcon.icns 155 | .com.apple.timemachine.donotpresent 156 | 157 | # Directories potentially created on remote AFP share 158 | .AppleDB 159 | .AppleDesktop 160 | Network Trash Folder 161 | Temporary Items 162 | .apdisk 163 | 164 | # Visual Studio Code 165 | .vscode/* 166 | !.vscode/settings.json 167 | !.vscode/tasks.json 168 | !.vscode/launch.json 169 | !.vscode/extensions.json 170 | *.code-workspace 171 | 172 | # Local History for Visual Studio Code 173 | .history/ 174 | 175 | # Built Visual Studio Code Extensions 176 | *.vsix -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "stable-channels" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [features] 7 | default = [] 8 | user = [] 9 | lsp = [] 10 | exchange = [] 11 | 12 | [dependencies] 13 | chrono = "0.4" 14 | ldk-node = { git = "https://github.com/lightningdevkit/ldk-node.git", tag = "v0.5.0" } 15 | lightning = { version = "0.0.125", features = ["std"] } 16 | ureq = { version = "2.10.1", features = ["json"] } 17 | serde = { version = "1.0", features = ["derive"] } 18 | serde_json = "1.0" 19 | retry = "1.3" 20 | futures = "0.3" 21 | async-trait = "0.1" 22 | hex = "0.4.3" 23 | lazy_static = "1.4" 24 | dirs = "5.0" 25 | 26 | # GUI dependencies 27 | eframe = { version = "0.30.0" } 28 | egui = { version = "0.30.0", default-features = false, features = ["color-hex"] } 29 | egui_extras = { version = "0.30.0", features = ["default"] } 30 | qrcode = { version = "0.14" } 31 | image = { version = "0.24" } 32 | 33 | [package.metadata.bundle] 34 | name = "Stable Channels" 35 | identifier = "com.stablechannels" 36 | icon = ["icons/icon.icns"] 37 | version = "0.1.0" 38 | resources = ["assets", "resources"] 39 | copyright = "Copyright (c) 2025 Your Name" 40 | category = "Finance" 41 | short_description = "Bitcoin stable channels app" 42 | long_description = "A Bitcoin wallet with stable channels that maintains value in USD." -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![main on CLN v24.02.2](https://github.com/toneloc/stable-channels/actions/workflows/main_v24.02.yml/badge.svg?branch=main)](https://github.com/toneloc/stable-channels/actions/workflows/main_v24.02.yml) 2 | 3 | ## Stable Channels 4 | 5 | Stable Channels allows Lightning Network node operators to keep one side of a channel stable in dollar terms. The nodes receiving stability are called Stable Receivers, while their counterparts, who assume the extra price volatility, are called Stable Providers. 6 | 7 | Each node queries four price feeds every minute. Based on the updated price, they adjust the channel balance with their counterparty to keep the Stable Receiver's balance at a fixed dollar value (e.g., $10,000 of bitcoin). 8 | 9 | Both parties remain self-custodial and can opt out anytime via cooperative or forced on-chain channel closure. The project is designed for LDK, but also is compatible with LND and CLN. The LND and CLN implementations use Python. The LDK one uses Rust and has server and client front-end based on the Rust egui package. 10 | 11 | Links with examples: 12 | - **Basic example:** [Twitter thread](https://x.com/tonklaus/status/1729567459579945017) 13 | - **In-depth discussion:** [Delving Bitcoin](https://delvingbitcoin.org/t/stable-channels-peer-to-peer-dollar-balances-on-lightning) 14 | - **Project website:** [StableChannels.com](https://www.stablechannels.com) 15 | 16 | ### Run the GUI Demo (LDK + Rust) 17 | 18 | To run this demo, you will need Rust installed on a Unix-like OS. You must also be connected to the internet to use Mutinynet for testing. 19 | 20 | Using a fresh Ubuntu? You may need to install OpenSSL libraries. `sudo apt-get install -y pkg-config libssl-dev` and `curl`. 21 | 22 | Clone the repo `git clone https://github.com/toneloc/stable-channels` and `cd` into the directory in **three windows**. 23 | 24 | #### Steps: 25 | 26 | 1. **Start up the app.** 27 | 28 | - In one window, run: 29 | 30 | ```bash 31 | cargo run -- user 32 | ``` 33 | 34 | - In the second window, run: 35 | 36 | ```bash 37 | cargo run -- lsp 38 | ``` 39 | 40 | 2. **Get some test BTC** 41 | 42 | - In the **user window**, run: 43 | 44 | ```bash 45 | getaddress 46 | ``` 47 | 48 | - Go to [Mutinynet Faucet](https://faucet.mutinynet.com/) and send some test sats to the address you obtained. 49 | 50 | - In the **user window**, run: 51 | 52 | ```bash 53 | balance 54 | ``` 55 | 56 | Wait until your BTC shows up there. For testing, we'll go ahead and use this to fund both sides of the channel. 57 | 58 | 3. **Open the Stable Channel** 59 | 60 | - Open a channel by running in the user window: 61 | 62 | ```bash 63 | openchannel [NODE_ID] [LISTENING_ADDRESS] [SATS_AMOUNT] 64 | openchannel 02a4b5670f4c756e8dd541a4966e1f68183eafacdb14e2e58d03f4d47b8ca72222 127.0.0.1:9737 320000 65 | ``` 66 | 67 | - Then, run: 68 | 69 | ```bash 70 | listallchannels 71 | ``` 72 | 73 | Check if `"channel_ready"` equals `"true"`. This will take 6 confirmations or a minute or two. 74 | 75 | 4. **Start the Stable Channel for Both Users** 76 | 77 | | Window | Command | Example Command | 78 | |------------------|----------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| 79 | | **User Window** | `startstablechannel CHANNEL_ID IS_STABLE_RECEIVER EXPECTED_DOLLAR_AMOUNT EXPECTED_BTC_AMOUNT` | ` startstablechannel cca0a... true 100.0 0` | 80 | | **LSP Window** | `startstablechannel CHANNEL_ID IS_STABLE_RECEIVER EXPECTED_DOLLAR_AMOUNT EXPECTED_BTC_AMOUNT` | ` startstablechannel cca0a... false 100.0 0` | 81 | 82 | - This command means: 83 | 84 | > Make the channel with ID `cca0a...` a stable channel with a value of $100.0 and 0 native bitcoin, where it is `true` (or `false`) that I am the stable receiver. 85 | 86 | ### Stable Channels Process 87 | 88 | Every 1 minute, the price of bitcoin: 89 | 90 | - **(a) Goes up:** 91 | - **Stable Receiver loses bitcoin.** 92 | - Less bitcoin is needed to maintain the dollar value. 93 | - The Stable Receiver pays the Stable Provider. 94 | 95 | - **(b) Goes down:** 96 | - **Stable Receiver gains bitcoin.** 97 | - More bitcoin is needed to maintain the dollar value. 98 | - The Stable Provider pays the Stable Receiver. 99 | 100 | - **(c) Stays the same:** 101 | - **No action required.** 102 | 103 | *Note: Stable Channels are currently non-routing channels. Work is ongoing to add routing and payment capabilities.* 104 | 105 | ## Getting Started with LND and CLN 106 | 107 | - **Supported Implementations:** 108 | - **CLN Plugin:** `stablechannels.py` 109 | - **Standalone LND App:** `lnd.py` 110 | - **Rust App (LDK):** Located in `src` (in development) 111 | - **Additional Resources:** Explore `/platforms` for mobile apps, web apps, scripts, and servers. 112 | 113 | ### Environment and dependencies 114 | 115 | - **LDK Version:** 116 | - Requires Rust. 117 | - **CLN and LND Versions:** 118 | - Requires Python 3. 119 | - **CLN Node:** Versions 23.05.2 or 24.02 recommended. 120 | - Version 23.08.1 is not supported. 121 | - **LND Node:** Tested with version 0.17.4-beta. 122 | - **Dependencies Installation:** 123 | - Run `pip3 install -r requirements.txt` or install individually. 124 | - **Balance Logs:** 125 | - **Stable Receiver:** `stablelog1.json` 126 | - **Stable Provider:** `stablelog2.json` 127 | - Located in `~/.lightning/bitcoin/stablechannels/` 128 | 129 | For CLN, clone this repo, or create a `stablechannels.py` file with the contents of `stablechannels.py` for CLN. 130 | 131 | ### Connecting and creating a dual-funded channel (for CLN) 132 | 133 | If your Lightning Node is running, you will need to stop your Lightning Node and restart it with the proper commands for dual-funded (or interactive) channels. 134 | 135 | You can do this with the following commands. 136 | 137 | Stop Lightning: `lightning-cli stop` or `lightning-cli --testnet stop`. 138 | 139 | Next, start your CLN node, or modify your config files, to enable dual-funding channels up to the amount you want to stabilize, or leverage. This will look like this 140 | 141 | ```bash 142 | lightningd --daemon --log-file=/home/ubuntu/cln.log --lightning-dir=/home/ubuntu/lightning --experimental-dual-fund --funder-policy=match --funder-policy-mod=100 --funder-min-their-funding=200000 --funder-per-channel-max=300000 --funder-fuzz-percent=0 --lease-fee-base-sat=2sat --lease-fee-basis=50 --experimental-offers --funder-lease-requests-only=false 143 | ``` 144 | The "funder" flags instruct CLN on how to handle dual-funded channels. Basically this command is saying: "This node is willing to fund a dual-funded channel up to **300000** sats, a minimum of **200000** sats, plus some other things not relevant for Stable Channels. 145 | 146 | Your counterparty will need to run a similar command. 147 | 148 | Next connect to your counterparty running the CLN `connect` command. This will look something like: `lightning-cli connect 021051a25e9798698f9baad3e7c815da9d9cc98221a0f63385eb1339bfc637ca81 54.314.42.1` 149 | 150 | Now you are ready to dual-fund. This command will look something like `lightning-cli fundchannel 021051a25e9798698f9baad3e7c815da9d9cc98221a0f63385eb1339bfc637ca81 0.0025btc` 151 | 152 | If all goes well, we should be returned a txid for the dual-funded channel, and both parties should have contributed 0.0025btc to the channel. 153 | 154 | Now this needs to be confirmed on the blockchain. 155 | 156 | ### Starting Stable Channels 157 | 158 | We need to start the Stable Channels plugin with the relevant details of the Stable Channel. 159 | 160 | The plugin startup command will look something like this for CLN: 161 | 162 | ```bash 163 | lightning-cli plugin subcommand=start plugin=/home/clightning/stablechannels.py channel-id=b37a51423e67a1f6733a78bb654535b2b81c427435600b0756bb65e21bdd411a stable-dollar-amount=95 is-stable-receiver=True counterparty=026b9c2a005b182ff5b2a7002a03d6ea9d005d18ed2eb3113852d679b3ec3832c2 native-btc-amount=0 164 | ``` 165 | 166 | Modify the directory for your plugin. 167 | 168 | What this command says is: "Start the plugin at this directory. Make the Lightning channel with channel ID b37a51423e67a1f6733a78bb654535b2b81c427435600b0756bb65e21bdd411a a stable channel at $95.00. and 0 sats of BTC. Is is `True` that the node running this command is the Stable Receiver. Here's the ID of the counterparty `026b9c..`." 169 | 170 | Your counterparty will need to run a similar command, and the Stable Channels software should do the rest. 171 | 172 | The startup command for the LND plugin will be something like this: 173 | 174 | ```bash 175 | python3 lnd.py 176 | --tls-cert-path=/Users/alice/tls.cert 177 | --expected-dollar-amount=100 178 | --channel-id=137344322632000 179 | --is-stable-receiver=false 180 | --counterparty=020c66e37461e9f9802e80c16cc0d97151c6da361df450dbca276478dc7d0c271e 181 | --macaroon-path=/Users/alice/admin.macaroon 182 | --native-amount-sat=0 183 | --lnd-server-url=https://127.0.0.1:8082 184 | ``` 185 | 186 | Stable Channel balance results for the Stable Receiver are written to the `stablelog1.json` file and logs for the Stable Provider are written to the `stablelog2.json` file. 187 | 188 | ## Payout matrix 189 | 190 | Assume that we enter into a stable agreement at a price of $60,000 per bitcoin. Each side puts in 1 bitcoin, for a total channel capacity of 2 bitcoin, and a starting USD nominal value of $120,000 total. The below table represents the payouts and percentage change if the bitcoin price increases or decreases by 10%, 20%, or 30%. Check out this payout matrix to better understand the mechanics of the trade agreement. 191 | 192 | Abbreviations: 193 | - SR = Stable Receiver 194 | - SP = Stable Provider 195 | - Δ = Delta / Change 196 | 197 | | Price Change (%) | New BTC Price | SR (BTC) | SR (USD) | SP (BTC) | SP (USD) | SR Fiat Δ$ | SR BTC Δ | SR Fiat Δ% | SR BTC Δ% | SP Fiat Δ$ | SP BTC Δ | SP Fiat Δ% | SP BTC Δ% | 198 | |------------------|---------------|----------|----------|----------|----------|------------|----------|------------|----------|------------|----------|------------|----------| 199 | | -30 | 42000.0 | 1.4286 | 60000 | 0.5714 | 42000.0 | 0 | +0.4286 | 0% | +42.86% | -18000.0 | -0.4286 | -60% | -42.86% | 200 | | -20 | 48000.0 | 1.25 | 60000 | 0.75 | 48000.0 | 0 | +0.25 | 0% | +25% | -12000.0 | -0.25 | -40% | -25% | 201 | | -10 | 54000.0 | 1.1111 | 60000 | 0.8889 | 54000.0 | 0 | +0.1111 | 0% | +11.11% | -6000.0 | -0.1111 | -20% | -11.11% | 202 | | 0 | 60000.0 | 1 | 60000 | 1 | 60000.0 | 0 | 0 | 0% | 0% | 0 | 0 | 0% | 0% | 203 | | 10 | 66000.0 | 0.9091 | 60000 | 1.0909 | 66000.0 | 0 | -0.0909 | 0% | -9.09% | +6000.0 | +0.0909 | +20% | +9.09% | 204 | | 20 | 72000.0 | 0.8333 | 60000 | 1.1667 | 72000.0 | 0 | -0.1667 | 0% | -16.67% | +12000.0 | +0.1667 | +40% | +16.67% | 205 | | 30 | 78000.0 | 0.7692 | 60000 | 1.2308 | 78000.0 | 0 | -0.2308 | 0% | -23.08% | +18000.0 | +0.2308 | +60% | +23.08% | 206 | 207 | 208 | ## Roadmap 209 | 210 | Hope to move all this to issues and PRs soon. 211 | 212 | #### Done: 213 | - [x] bash script version 214 | - [x] first CLN plugin version 215 | - [x] LND version 216 | - [x] first Python app version 217 | - [x] test Greenlight integration 218 | - [x] price feed integration 219 | - [x] UTXOracle plugin - https://github.com/toneloc/plugins/blob/master/utxoracle/utxoracle.py 220 | - [x] dual-funded flow 221 | - [x] mainnet deployment 222 | - [x] Add native field / partially stable 223 | - [x] user feedback on CLN plugin 224 | - [x] LDK version 225 | 226 | #### To do: 227 | - [ ] LSP just-in-time channel integration 228 | - [ ] read-only iPhone app published in App Store 229 | - [ ] manage channel creation via `fundchannel` command 230 | - [ ] monitor channel creation tx, and commence `check_stables` after 231 | - [ ] move Stable Channels details to conf files (*) 232 | - [ ] use CLN `datastore` command to manage Stable Channel details (?) 233 | - [ ] accounting commands 234 | - [ ] Python Greenlight integration 235 | - [ ] trading web app 236 | - [ ] VLS integration 237 | - [ ] read-only Android app published in App Store 238 | - [ ] crypto keys on mobile 239 | - [ ] FinalBoss plugin 240 | 241 | ## Rationale and Challenges 242 | 243 | This Delving Bitcoin post goes more in-depth on challenges and opportunities - https://delvingbitcoin.org/t/stable-channels-peer-to-peer-dollar-balances-on-lightning 244 | 245 | ### Acknowledgements 246 | 247 | Thanks to Christian Decker and the Core Lightning team from Blockstream for his help with setting up Greenlight. Thanks to Michael Schmoock (m-schmoock) for writing the "currencyrate" plugin, which I use. Thanks to @jamaljsr for developing the Polar Lightning Network visualization tool. I also used Jamal's code for the Stable Channels.com website. Thanks to Dan Robinson for his work on Rainbow Channels. Thanks to Daywalker90 and StarBuilder for open-source contributions. 248 | 249 | Thanks to all of the Lightning Network core developers, and all of the bitcoin open-source devs on whose giant shoulders we stand. 250 | -------------------------------------------------------------------------------- /demo-script.md: -------------------------------------------------------------------------------- 1 | # Stable Channels + Rust + LDK + just-in-time channels 2 | 3 | ## Actors / roles in this demo 4 | 5 | Each of these three actor runs a Lightning Development Kit (LDK) Lightning Node. 6 | 7 | Each actor remains self-custodial. 8 | 9 | 1. **Exchange**: Lightning-enabled exchange, like Coinbase or Kraken. 10 | 2. **User**: This self-custodial user wants the USD stability, also known as the Stable Receiver. 11 | 3. **LSP**: "Lightning Service Provider." This actor is the Stable Provider. 12 | 13 | ```mermaid 14 | graph LR 15 | Exchange <---> LSP/Server <---> User/Mobile 16 | ``` 17 | 18 | ## Prerequisites 19 | 20 | To run this demo, you will need Rust installed. You must also be connected to the internet to use Mutinynet for testing. 21 | 22 | Clone the repo and open it in two windows. 23 | 24 | ## Walkthrough 25 | 26 | In this example, a user onboards to a Stable Channel from an exchange. 27 | 28 | The user onboards by paying himselg via a Bolt11 Lightning invoice. The LSP creates this channel for the user and provides this stabiltiy service. 29 | 30 | ## Step 1 - Start the app 31 | 32 | - In one window, run: 33 | 34 | ```bash 35 | cargo run --features user 36 | ``` 37 | 38 | - In the other window, run: 39 | 40 | ```bash 41 | cargo run --features lsp 42 | ``` 43 | 44 | - In a third window, run: 45 | 46 | ```bash 47 | cargo run --features exchange 48 | ``` 49 | 50 | 51 | then 52 | 53 | ``lsp getaddress`` 54 | 55 | and 56 | 57 | ``exchange getaddress`` 58 | 59 | Go to https://faucet.mutinynet.com/ and send some test sats to these two addresses. Wait for them to confirm. 60 | 61 | ``lsp balance`` 62 | 63 | and 64 | 65 | ``exchange balance`` 66 | 67 | ### Step 2 - Open a routing channel 68 | 69 | Open a channel between the exchange and the LSP. We will use this for routing. 70 | 71 | ``exchange openchannel`` 72 | 73 | Let's see if the channel got confirmed on the blockchain. Check if "channel_ready" equals "true." 74 | 75 | ``lsp listallchannels`` 76 | 77 | or 78 | 79 | ``exchange listallchannels`` 80 | 81 | ### Step 3 - Create a JIT Invoice 82 | 83 | Create a JIT invoice that will route from the exchange, through the Lightning Service Provider, and finally to the user. 84 | 85 | ``user getjitinvoice`` 86 | 87 | ### Step 4 - Pay the JIT Invoice 88 | 89 | The LSP intercepts the payment, takes out a channel open fee, puts in matching Liquidity, and sends the rest to the user. 90 | 91 | ``exchange payjitinvoice`` 92 | 93 | Now the LSP has two channels. 1 to the exchange and one to the user. 94 | 95 | ``lsp listallchannels`` 96 | 97 | And the user has one channel: 98 | 99 | ``user listallchannels`` 100 | 101 | ### Step 5 - Start a stable channel 102 | 103 | Using the command: 104 | 105 | ``user startstablechannel CHANNEL_ID IS_STABLE_RECEIVER EXPECTED_DOLLAR_AMOUNT EXPECTED_BTC_AMOUNT`` 106 | 107 | or: 108 | 109 | ``user startstablechannel cca0a4c065e678ad8aecec3ae9a6d694d1b5c7512290da69b32c72b6c209f6e2 true 4.0 0`` 110 | 111 | -------------------------------------------------------------------------------- /lnd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | # This is the LND Python app for Stable Channels 4 | 5 | # Stable Channels: p2p BTCUSD trading on Lightning 6 | # Contents 7 | # Section 1 - Dependencies and main data structure 8 | # Section 2 - Price feed config and logic 9 | # Section 3 - Core logic 10 | # Section 4 - Initialization 11 | 12 | 13 | # Section 1 - Dependencies and main data structure 14 | # Dependencies 15 | from cachetools import cached, TTLCache # Used to handle price feed calls; probably can remove 16 | import requests # Standard on Python 3.7+ 17 | from requests.adapters import HTTPAdapter 18 | from collections import namedtuple 19 | from requests.packages.urllib3.util.retry import Retry 20 | import statistics # Standard on Python 3 21 | import time # Standard on Python 3 22 | from datetime import datetime 23 | from apscheduler.schedulers.blocking import BlockingScheduler # Used to check balances every 5 minutes 24 | import threading # Standard on Python 3 25 | import argparse 26 | import codecs # Encodes macaroon as hex 27 | from hashlib import sha256 28 | from secrets import token_hex 29 | import base64 30 | 31 | # Main data structure 32 | class StableChannel: 33 | def __init__( 34 | self, 35 | channel_id: str, 36 | expected_dollar_amount: float, 37 | native_amount_msat: int, 38 | is_stable_receiver: bool, 39 | counterparty: str, 40 | our_balance: float, 41 | their_balance: float, 42 | risk_score: int, 43 | stable_receiver_dollar_amount: float, 44 | stable_provider_dollar_amount: float, 45 | timestamp: int, 46 | formatted_datetime: str, 47 | payment_made: bool, 48 | lnd_server_url: str, 49 | macaroon_hex: str, 50 | tls_cert_path: str 51 | 52 | 53 | ): 54 | self.channel_id = channel_id 55 | self.expected_dollar_amount = expected_dollar_amount 56 | self.native_amount_msat = native_amount_msat 57 | self.is_stable_receiver = is_stable_receiver 58 | self.counterparty = counterparty 59 | self.our_balance = our_balance 60 | self.their_balance = their_balance 61 | self.risk_score = risk_score 62 | self.stable_receiver_dollar_amount = stable_receiver_dollar_amount 63 | self.stable_provider_dollar_amount = stable_provider_dollar_amount 64 | self.timestamp = timestamp 65 | self.formatted_datetime = datetime 66 | self.payment_made = payment_made 67 | self.lnd_server_url = lnd_server_url 68 | self.macaroon_hex = macaroon_hex 69 | self.tls_cert_path = tls_cert_path 70 | 71 | def __str__(self): 72 | return (f"StableChannel(channel_id={self.channel_id}, " 73 | f"native_amount_msat={self.native_amount_msat}, " 74 | f"expected_dollar_amount={self.expected_dollar_amount}, " 75 | f"is_stable_receiver={self.is_stable_receiver}, " 76 | f"counterparty={self.counterparty}, " 77 | f"our_balance={self.our_balance}, " 78 | f"their_balance={self.their_balance}, " 79 | f"risk_score={self.risk_score}, " 80 | f"stable_receiver_dollar_amount={self.stable_receiver_dollar_amount}, " 81 | f"stable_provider_dollar_amount={self.stable_provider_dollar_amount}, " 82 | f"timestamp={self.timestamp}, " 83 | f"formatted_datetime={self.formatted_datetime}, " 84 | f"payment_made={self.payment_made}, " 85 | f"lnd_server_url={self.lnd_server_url}, " 86 | f"macaroon_hex={self.macaroon_hex}, " 87 | f"tls_cert_path={self.tls_cert_path})") 88 | 89 | 90 | # Section 2 - Price feed config and logic 91 | Source = namedtuple('Source', ['name', 'urlformat', 'replymembers']) 92 | 93 | # 5 price feed sources 94 | sources = [ 95 | # e.g. {"high": "18502.56", "last": "17970.41", "timestamp": "1607650787", "bid": "17961.87", "vwap": "18223.42", "volume": "7055.63066541", "low": "17815.92", "ask": "17970.41", "open": "18250.30"} 96 | Source('bitstamp', 97 | 'https://www.bitstamp.net/api/v2/ticker/btc{currency_lc}/', 98 | ['last']), 99 | # e.g. {"bitcoin":{"usd":17885.84}} 100 | Source('coingecko', 101 | 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies={currency_lc}', 102 | ['bitcoin', '{currency_lc}']), 103 | # e.g. {"time":{"updated":"Dec 16, 2020 00:58:00 UTC","updatedISO":"2020-12-16T00:58:00+00:00","updateduk":"Dec 16, 2020 at 00:58 GMT"},"disclaimer":"This data was produced from the CoinDesk Bitcoin Price Index (USD). Non-USD currency data converted using hourly conversion rate from openexchangerates.org","bpi":{"USD":{"code":"USD","rate":"19,395.1400","description":"United States Dollar","rate_float":19395.14},"AUD":{"code":"AUD","rate":"25,663.5329","description":"Australian Dollar","rate_float":25663.5329}}} 104 | # Source('coindesk', 105 | # 'https://api.coindesk.com/v1/bpi/currentprice/{currency}.json', 106 | # ['bpi', '{currency}', 'rate_float']), 107 | # e.g. {"data":{"base":"BTC","currency":"USD","amount":"19414.63"}} 108 | Source('coinbase', 109 | 'https://api.coinbase.com/v2/prices/spot?currency={currency}', 110 | ['data', 'amount']), 111 | # e.g. { "USD" : {"15m" : 6650.3, "last" : 6650.3, "buy" : 6650.3, "sell" : 6650.3, "symbol" : "$"}, "AUD" : {"15m" : 10857.19, "last" : 10857.19, "buy" : 10857.19, "sell" : 10857.19, "symbol" : "$"},... 112 | Source('blockchain.info', 113 | 'https://blockchain.info/ticker', 114 | ['{currency}', 'last']), 115 | ] 116 | 117 | # Request logic is from "currencyrate" plugin: 118 | # https://github.com/lightningd/plugins/blob/master/currencyrate 119 | def requests_retry_session( 120 | retries=3, 121 | backoff_factor=0.3, 122 | status_forcelist=(500, 502, 504), 123 | session=None, 124 | ): 125 | session = session or requests.Session() 126 | retry = Retry( 127 | total=retries, 128 | read=retries, 129 | connect=retries, 130 | backoff_factor=backoff_factor, 131 | status_forcelist=status_forcelist, 132 | ) 133 | adapter = HTTPAdapter(max_retries=retry) 134 | session.mount('http://', adapter) 135 | session.mount('https://', adapter) 136 | return session 137 | 138 | def get_currencyrate(currency, urlformat, replymembers): 139 | # NOTE: Bitstamp has a DNS/Proxy issues that can return 404 140 | # Workaround: retry up to 5 times with a delay 141 | currency_lc = currency.lower() 142 | url = urlformat.format(currency_lc=currency_lc, currency=currency) 143 | r = requests_retry_session(retries=5, status_forcelist=[404]).get(url) 144 | 145 | if r.status_code != 200: 146 | # plugin.log(level='info', message='{}: bad response {}'.format(url, r.status_code)) 147 | return None 148 | 149 | json = r.json() 150 | for m in replymembers: 151 | expanded = m.format(currency_lc=currency_lc, currency=currency) 152 | if expanded not in json: 153 | # plugin.log(level='debug', message='{}: {} not in {}'.format(url, expanded, json)) 154 | return None 155 | json = json[expanded] 156 | 157 | try: 158 | return int(10**11 / float(json)) 159 | except Exception: 160 | print(" could not convert to sat'.format(url, json))") 161 | return None 162 | 163 | # Cache returns cached result if <60 seconds old. 164 | # Stable Channels may not need 165 | @cached(cache=TTLCache(maxsize=1024, ttl=60)) 166 | def get_rates(currency): 167 | rates = {} 168 | for s in sources: 169 | r = get_currencyrate(currency, s.urlformat, s.replymembers) 170 | if r is not None: 171 | rates[s.name] = r 172 | 173 | print("msats per dollar from exchanges: ",rates) 174 | return rates 175 | 176 | def currencyconvert(amount, currency): 177 | """Converts currency using given APIs.""" 178 | rates = get_rates(currency.upper()) 179 | if len(rates) == 0: 180 | raise Exception("No values available for currency {}".format(currency.upper())) 181 | 182 | val = statistics.median([m for m in rates.values()]) * float(amount) 183 | 184 | estimated_price = "{:.2f}".format(100000000000 / statistics.median([m for m in rates.values()])) 185 | 186 | return ({"msat": round(val)}, estimated_price) 187 | 188 | # Section 3 - Core logic 189 | 190 | # Helper functions 191 | 192 | def b64_hex_transform(plain_str: str) -> str: 193 | """Returns the b64 transformed version of a hex string""" 194 | a_string = bytes.fromhex(plain_str) 195 | return base64.b64encode(a_string).decode() 196 | 197 | def get_channel_info(sc): 198 | url = sc.lnd_server_url + '/v1/channels' 199 | headers = {'Grpc-Metadata-macaroon': sc.macaroon_hex} 200 | response = requests.get(url, headers=headers, verify=sc.tls_cert_path) 201 | return response.json() 202 | 203 | def update_our_and_their_balance(sc, channels_data): 204 | for channel in channels_data['channels']: 205 | if channel['chan_id'] == sc.channel_id: 206 | # LND keeps a small balance in reserve for channel closure 207 | # LNS doesn't show it in "balance" data parameter, so we add it here 208 | local_chan_reserve_msat = int(channel['commit_fee']) * 1000 209 | # This following line may be troublesome +/- 20 sats if LND node wants to be a Stable Provider 210 | remote_chan_reserve_msat = int(channel['remote_chan_reserve_sat']) * 1000 211 | # The addition of these 660 sats are required if the channel open uses anchor outputs 212 | sc.our_balance = (int(channel['local_balance']) * 1000) + local_chan_reserve_msat + int(660000) 213 | sc.their_balance = (int(channel['remote_balance']) * 1000) + remote_chan_reserve_msat 214 | return 215 | print("Could not find channel") 216 | 217 | def b64_transform(plain_str: str) -> str: 218 | """Returns the b64 transformed version of a string""" 219 | return base64.b64encode(plain_str.encode()).decode() 220 | 221 | # This function is the scheduler, formatted to fire every 5 minutes 222 | def start_scheduler(sc): 223 | scheduler = BlockingScheduler() 224 | scheduler.add_job(check_stables, 'cron', minute='0/5', args=[sc]) 225 | scheduler.start() 226 | pass 227 | 228 | def calculate_stable_receiver_dollar_amount(sc, balance, expected_msats): 229 | return round((int(balance - sc.native_amount_msat) * sc.expected_dollar_amount) / int(expected_msats), 3) 230 | 231 | def keysend_payment(sc, amount_msat): 232 | dest = b64_hex_transform(sc.counterparty) 233 | pre_image = token_hex(32) 234 | payment_hash = sha256(bytes.fromhex(pre_image)).hexdigest() 235 | dest_custom_records = { 236 | 5482373484: b64_hex_transform(pre_image), 237 | 34349334: b64_transform("Custom Stable Channel message here, when needed."), 238 | } 239 | url = sc.lnd_server_url + '/v1/channels/transactions' 240 | headers = {'Grpc-Metadata-macaroon': sc.macaroon_hex} 241 | 242 | data = { 243 | "dest": dest, 244 | "amt": int(amount_msat / 1000), 245 | "payment_hash": b64_hex_transform(payment_hash), 246 | "dest_custom_records": dest_custom_records, 247 | } 248 | print("") 249 | print(str(data)) 250 | response = requests.post(url=url, headers=headers, json=data, verify=sc.tls_cert_path) 251 | return response 252 | 253 | # Main function 254 | 255 | # 5 scenarios to handle 256 | # Scenario 1 - Difference to small to worry about (under $0.01) = do nothing 257 | # Scenario 2 - Node is stableReceiver and expects to get paid = wait 30 seconds; check on payment 258 | # Scenario 3 - Node is stableProvider and needs to pay = keysend and exit 259 | # Scenario 4 - Node is stableReceiver and needs to pay = keysend and exit 260 | # Scenario 5 - Node is stableProvider and expects to get paid = wait 30 seconds; check on payment 261 | # "sc" = "Stable Channel" object 262 | 263 | def check_stables(sc): 264 | msat_dict, estimated_price = currencyconvert(sc.expected_dollar_amount, "USD") 265 | expected_msats = msat_dict["msat"] 266 | print("expected msats") 267 | print(expected_msats) 268 | 269 | channels_data = get_channel_info(sc) 270 | print(channels_data) 271 | update_our_and_their_balance(sc, channels_data) 272 | print("Our balance = " + str(sc.our_balance)) 273 | print("Their balance = " + str(sc.their_balance)) 274 | 275 | if sc.is_stable_receiver: 276 | adjustedBalance = sc.our_balance - sc.native_amount_msat 277 | else: 278 | adjustedBalance = sc.their_balance - sc.native_amount_msat 279 | 280 | sc.stable_receiver_dollar_amount = calculate_stable_receiver_dollar_amount(sc, adjustedBalance, expected_msats) 281 | formatted_time = datetime.utcnow().strftime("%H:%M %d %b %Y") 282 | print(formatted_time) 283 | 284 | sc.payment_made = False 285 | amount_too_small = False 286 | 287 | # Scenario 1 - Difference too small to worry about (under $0.01) = do nothing 288 | if abs(sc.expected_dollar_amount - float(sc.stable_receiver_dollar_amount)) < 0.01: 289 | print("Scenario 1 - Difference too small to worry about (under $0.01)") 290 | amount_too_small = True 291 | sc.payment_made = False 292 | 293 | if not amount_too_small: 294 | current_stable_receiver_balance = sc.our_balance if sc.is_stable_receiver else sc.their_balance 295 | msat_difference_from_expected = round(abs(int(expected_msats) - int(current_stable_receiver_balance))) 296 | 297 | # Scenario 2 - Node is stableReceiver and expects to get paid = wait 30 seconds; check on payment 298 | if sc.stable_receiver_dollar_amount < sc.expected_dollar_amount and sc.is_stable_receiver: 299 | print("Scenario 2 - Node is stableReceiver and expects to get paid ") 300 | time.sleep(30) 301 | channels_data = get_channel_info(sc) 302 | update_our_and_their_balance(sc, channels_data) 303 | new_our_stable_balance_msat = sc.our_balance - sc.native_amount_msat 304 | new_stable_receiver_dollar_amount = calculate_stable_receiver_dollar_amount(sc, new_our_stable_balance_msat, expected_msats) 305 | if sc.expected_dollar_amount - float(new_stable_receiver_dollar_amount) < 0.01: 306 | sc.payment_made = True 307 | else: 308 | sc.risk_score += 1 309 | 310 | # Scenario 3 - Node is stableProvider and needs to pay = keysend and exit 311 | elif not sc.is_stable_receiver and sc.stable_receiver_dollar_amount < sc.expected_dollar_amount: 312 | 313 | print("Scenario 3 - Node is stableProvider and needs to pay") 314 | 315 | response = keysend_payment(sc, msat_difference_from_expected) 316 | 317 | if response.status_code == 200: 318 | print("Keysend successful:", response.json()) 319 | else: 320 | print("Failed to send keysend:", response.status_code, response.text) 321 | sc.payment_made = True 322 | 323 | # Scenario 4 - Node is stableReceiver and needs to pay = keysend and exit 324 | elif sc.is_stable_receiver and sc.stable_receiver_dollar_amount > sc.expected_dollar_amount: 325 | print("Scenario 4 - Node is stableReceiver and needs to pay") 326 | response = keysend_payment(sc, msat_difference_from_expected) 327 | if response.status_code == 200: 328 | print("Keysend successful:", response.json()) 329 | else: 330 | print("Failed to send keysend:", response.status_code, response.text) 331 | sc.payment_made = True 332 | 333 | # Scenario 5 - Node is stableProvider and expects to get paid = wait 30 seconds; check on payment 334 | elif not sc.is_stable_receiver and sc.stable_receiver_dollar_amount > sc.expected_dollar_amount: 335 | print("Scenario 5 - Node is stableProvider and expects to get paid") 336 | time.sleep(30) 337 | channels_data = get_channel_info(sc) 338 | update_our_and_their_balance(sc, channels_data) 339 | new_their_stable_balance_msat = sc.their_balance - sc.native_amount_msat 340 | new_stable_receiver_dollar_amount = calculate_stable_receiver_dollar_amount(sc, new_their_stable_balance_msat, expected_msats) 341 | if sc.expected_dollar_amount - float(new_stable_receiver_dollar_amount) > 0.01: 342 | sc.payment_made = True 343 | else: 344 | sc.risk_score += 1 345 | 346 | json_line = f'{{"formatted_time": "{formatted_time}", "estimated_price": {estimated_price}, "expected_dollar_amount": {sc.expected_dollar_amount}, "stable_receiver_dollar_amount": {sc.stable_receiver_dollar_amount}, "payment_made": {sc.payment_made}, "risk_score": {sc.risk_score}, "our_new_balance_msat": {sc.our_balance}, "their_new_balance_msat": {sc.their_balance}}},\n' 347 | 348 | file_path = '/Users/t/Desktop/stable-channels/stablelog1.json' if sc.is_stable_receiver else '/Users/t/Desktop/stable-channels/stablelog2.json' 349 | with open(file_path, 'a') as file: 350 | file.write(json_line) 351 | 352 | def main(): 353 | parser = argparse.ArgumentParser(description='LND Script Arguments') 354 | parser.add_argument('--lnd-server-url', type=str, required=True, help='LND server address') 355 | parser.add_argument('--macaroon-path', type=str, required=True, help='Hex-encoded macaroon for authentication') 356 | parser.add_argument('--tls-cert-path', type=str, required=True, help='TLS cert path for server authentication') 357 | parser.add_argument('--expected-dollar-amount', type=float, required=True, help='Expected dollar amount') 358 | parser.add_argument('--channel-id', type=str, required=True, help='LND channel ID') 359 | parser.add_argument('--native-amount-sat', type=float, required=True, help='Native amount in msat') 360 | parser.add_argument('--is-stable-receiver', type=lambda x: (str(x).lower() == 'true'), required=True, help='Is stable receiver flag') 361 | parser.add_argument('--counterparty', type=str, required=True, help='LN Node ID of counterparty') 362 | 363 | args = parser.parse_args() 364 | 365 | print(args.lnd_server_url) 366 | 367 | sc = StableChannel( 368 | channel_id=args.channel_id, 369 | native_amount_msat=int(args.native_amount_sat * 1000), 370 | expected_dollar_amount=args.expected_dollar_amount, 371 | is_stable_receiver=args.is_stable_receiver, 372 | counterparty=args.counterparty, 373 | our_balance=0, 374 | their_balance=0, 375 | risk_score=0, 376 | stable_receiver_dollar_amount=0, 377 | stable_provider_dollar_amount=0, 378 | timestamp=0, 379 | formatted_datetime='', 380 | payment_made=False, 381 | lnd_server_url=args.lnd_server_url, 382 | macaroon_hex=codecs.encode(open(args.macaroon_path, 'rb').read(), 'hex'), 383 | tls_cert_path = args.tls_cert_path 384 | ) 385 | 386 | print("Initializating a Stable Channel with these details:") 387 | print(sc) 388 | 389 | thread = threading.Thread(target=start_scheduler, args=(sc,)) 390 | thread.start() 391 | thread.join() 392 | 393 | if __name__ == "__main__": 394 | main() 395 | 396 | # SAMPLE commands: 397 | 398 | # curl --cacert /Users/t/.polar/networks/8/volumes/lnd/alice/tls.cert \ 399 | # --header "Grpc-Metadata-macaroon: 0201036c6e6402f801030a103def9a17d1aa8476cb50a975bb3ef1ee1201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620f0e59d2963cb3246e9fd8d835ea47e056bf4eb80002ae98204b62bcb5ebf3809" \ 400 | # https://127.0.0.1:8081/v1/balance/channels 401 | 402 | # Alice local startup LND as Stable Receiver 403 | # python3 lnd.py --tls-cert-path=/Users/t/.polar/networks/18/volumes/lnd/alice/tls.cert --expected-dollar-amount=100 --channel-id=125344325632000 --is-stable-receiver=True --counterparty=031786135987ebd4c08999a4cbbae38f67f41828879d191a5c56092e408e1ce9c4 --macaroon-path=/Users/t/.polar/networks/18/volumes/lnd/alice/data/chain/bitcoin/regtest/admin.macaroon --native-amount-sat=0 --lnd-server-url=https://127.0.0.1:8081 404 | 405 | # Bob local startup LND as Stable Provider 406 | # python3 lnd.py --tls-cert-path=/Users/t/.polar/networks/18/volumes/lnd/bob/tls.cert --expected-dollar-amount=100 --channel-id=125344325632000 --is-stable-receiver=False --counterparty=030c66a66743e9f9802780c16cc0d97151c6dae61df450dbca276478dc7d0c931d --macaroon-path=/Users/t/.polar/networks/18/volumes/lnd/bob/data/chain/bitcoin/regtest/admin.macaroon --native-amount-sat=0 --lnd-server-url=https://127.0.0.1:8082 407 | -------------------------------------------------------------------------------- /platforms/Dockerfile: -------------------------------------------------------------------------------- 1 | ######################### 2 | # Polar needs c-lightning compiled with the DEVELOPER=1 flag in order to decrease the 3 | # normal 30 second bitcoind poll interval using the argument --dev-bitcoind-poll=. 4 | # When running in regtest, we want to be able to mine blocks and confirm transactions instantly. 5 | # Original Source: https://github.com/ElementsProject/lightning/blob/v24.02.2/Dockerfile 6 | ######################### 7 | 8 | ######################### 9 | # BEGIN ElementsProject/lightning/Dockerfile 10 | ######################### 11 | 12 | # This dockerfile is meant to compile a core-lightning x64 image 13 | # It is using multi stage build: 14 | # * downloader: Download litecoin/bitcoin and qemu binaries needed for core-lightning 15 | # * builder: Compile core-lightning dependencies, then core-lightning itself with static linking 16 | # * final: Copy the binaries required at runtime 17 | # The resulting image uploaded to dockerhub will only contain what is needed for runtime. 18 | # From the root of the repository, run "docker build -t yourimage:yourtag ." 19 | FROM debian:bullseye-slim as downloader 20 | 21 | RUN set -ex \ 22 | && apt-get update \ 23 | && apt-get install -qq --no-install-recommends ca-certificates dirmngr wget 24 | 25 | WORKDIR /opt 26 | 27 | 28 | ARG BITCOIN_VERSION=22.0 29 | ARG TARBALL_ARCH=x86_64-linux-gnu 30 | ENV TARBALL_ARCH_FINAL=$TARBALL_ARCH 31 | ENV BITCOIN_TARBALL bitcoin-${BITCOIN_VERSION}-${TARBALL_ARCH_FINAL}.tar.gz 32 | ENV BITCOIN_URL https://bitcoincore.org/bin/bitcoin-core-$BITCOIN_VERSION/$BITCOIN_TARBALL 33 | ENV BITCOIN_ASC_URL https://bitcoincore.org/bin/bitcoin-core-$BITCOIN_VERSION/SHA256SUMS 34 | 35 | RUN mkdir /opt/bitcoin && cd /opt/bitcoin \ 36 | #################### Polar Modification 37 | # We want to use the base image arch instead of the BUILDARG above so we can build the 38 | # multi-arch image with one command: 39 | # "docker buildx build --platform linux/amd64,linux/arm64 ..." 40 | #################### 41 | && TARBALL_ARCH_FINAL="$(uname -m)-linux-gnu" \ 42 | && BITCOIN_TARBALL=bitcoin-${BITCOIN_VERSION}-${TARBALL_ARCH_FINAL}.tar.gz \ 43 | && BITCOIN_URL=https://bitcoincore.org/bin/bitcoin-core-$BITCOIN_VERSION/$BITCOIN_TARBALL \ 44 | && BITCOIN_ASC_URL=https://bitcoincore.org/bin/bitcoin-core-$BITCOIN_VERSION/SHA256SUMS \ 45 | #################### 46 | && wget -qO $BITCOIN_TARBALL "$BITCOIN_URL" \ 47 | && wget -qO bitcoin "$BITCOIN_ASC_URL" \ 48 | && grep $BITCOIN_TARBALL bitcoin | tee SHA256SUMS \ 49 | && sha256sum -c SHA256SUMS \ 50 | && BD=bitcoin-$BITCOIN_VERSION/bin \ 51 | && tar -xzvf $BITCOIN_TARBALL $BD/ --strip-components=1 \ 52 | && rm $BITCOIN_TARBALL 53 | 54 | ENV LITECOIN_VERSION 0.16.3 55 | ENV LITECOIN_URL https://download.litecoin.org/litecoin-${LITECOIN_VERSION}/linux/litecoin-${LITECOIN_VERSION}-${TARBALL_ARCH_FINAL}.tar.gz 56 | 57 | # install litecoin binaries 58 | RUN mkdir /opt/litecoin && cd /opt/litecoin \ 59 | && wget -qO litecoin.tar.gz "$LITECOIN_URL" \ 60 | && tar -xzvf litecoin.tar.gz litecoin-$LITECOIN_VERSION/bin/litecoin-cli --strip-components=1 --exclude=*-qt \ 61 | && rm litecoin.tar.gz 62 | 63 | FROM debian:bullseye-slim as builder 64 | 65 | ENV LIGHTNINGD_VERSION=master 66 | RUN apt-get update -qq && \ 67 | apt-get install -qq -y --no-install-recommends \ 68 | autoconf \ 69 | automake \ 70 | build-essential \ 71 | ca-certificates \ 72 | curl \ 73 | dirmngr \ 74 | gettext \ 75 | git \ 76 | gnupg \ 77 | libpq-dev \ 78 | libtool \ 79 | libffi-dev \ 80 | pkg-config \ 81 | libssl-dev \ 82 | protobuf-compiler \ 83 | python3.9 \ 84 | python3-dev \ 85 | python3-mako \ 86 | python3-pip \ 87 | python3-venv \ 88 | python3-setuptools \ 89 | libev-dev \ 90 | libevent-dev \ 91 | qemu-user-static \ 92 | wget \ 93 | jq 94 | 95 | RUN wget -q https://zlib.net/fossils/zlib-1.2.13.tar.gz \ 96 | && tar xvf zlib-1.2.13.tar.gz \ 97 | && cd zlib-1.2.13 \ 98 | && ./configure \ 99 | && make \ 100 | && make install && cd .. && \ 101 | rm zlib-1.2.13.tar.gz && \ 102 | rm -rf zlib-1.2.13 103 | 104 | RUN apt-get install -y --no-install-recommends unzip tclsh \ 105 | && wget -q https://www.sqlite.org/2019/sqlite-src-3290000.zip \ 106 | && unzip sqlite-src-3290000.zip \ 107 | && cd sqlite-src-3290000 \ 108 | && ./configure --enable-static --disable-readline --disable-threadsafe --disable-load-extension \ 109 | && make \ 110 | && make install && cd .. && rm sqlite-src-3290000.zip && rm -rf sqlite-src-3290000 111 | 112 | USER root 113 | ENV RUST_PROFILE=release 114 | ENV PATH=$PATH:/root/.cargo/bin/ 115 | RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 116 | RUN rustup toolchain install stable --component rustfmt --allow-downgrade 117 | 118 | WORKDIR /opt/lightningd 119 | #################### Polar Modification 120 | # Pull source code from github instead of a local repo 121 | # Original lines: 122 | # COPY . /tmp/lightning 123 | # RUN git clone --recursive /tmp/lightning . && \ 124 | # git checkout $(git --work-tree=/tmp/lightning --git-dir=/tmp/lightning/.git rev-parse HEAD) 125 | ARG CLN_VERSION 126 | RUN git clone --recursive --branch=v24.02.2 https://github.com/ElementsProject/lightning . 127 | #################### 128 | 129 | ENV PYTHON_VERSION=3 130 | RUN curl -sSL https://install.python-poetry.org | python3 - 131 | 132 | RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.9 1 133 | 134 | RUN pip3 install --upgrade pip setuptools wheel 135 | RUN pip3 wheel cryptography 136 | RUN pip3 install grpcio-tools 137 | 138 | RUN /root/.local/bin/poetry export -o requirements.txt --without-hashes --with dev 139 | RUN pip3 install -r requirements.txt 140 | 141 | RUN ./configure --prefix=/tmp/lightning_install --enable-static && \ 142 | make && \ 143 | /root/.local/bin/poetry run make install 144 | 145 | FROM debian:bullseye-slim as final 146 | 147 | RUN apt-get update && \ 148 | apt-get install -y --no-install-recommends \ 149 | tini \ 150 | socat \ 151 | inotify-tools \ 152 | python3.9 \ 153 | python3-pip \ 154 | qemu-user-static \ 155 | libpq5 && \ 156 | apt-get clean && \ 157 | rm -rf /var/lib/apt/lists/* 158 | 159 | ENV LIGHTNINGD_DATA=/root/.lightning 160 | ENV LIGHTNINGD_RPC_PORT=9835 161 | ENV LIGHTNINGD_PORT=9735 162 | ENV LIGHTNINGD_NETWORK=bitcoin 163 | 164 | RUN mkdir $LIGHTNINGD_DATA && \ 165 | touch $LIGHTNINGD_DATA/config 166 | VOLUME [ "/root/.lightning" ] 167 | 168 | COPY --from=builder /tmp/lightning_install/ /usr/local/ 169 | COPY --from=builder /usr/local/lib/python3.9/dist-packages/ /usr/local/lib/python3.9/dist-packages/ 170 | COPY --from=downloader /opt/bitcoin/bin /usr/bin 171 | COPY --from=downloader /opt/litecoin/bin /usr/bin 172 | #################### Polar Modification 173 | # This line is removed as we have our own entrypoint file 174 | # Original line: 175 | # COPY tools/docker-entrypoint.sh entrypoint.sh 176 | #################### 177 | 178 | ######################### 179 | # END ElementsProject/lightning/Dockerfile 180 | ######################### 181 | 182 | COPY --from=builder /opt/lightningd/contrib/lightning-cli.bash-completion /etc/bash_completion.d/ 183 | 184 | # install nodejs 185 | RUN apt-get update -y \ 186 | && apt-get install -y curl gosu git \ 187 | && apt-get clean \ 188 | && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 189 | 190 | # install lightning-cli bash completion 191 | RUN curl -SLO https://raw.githubusercontent.com/scop/bash-completion/master/bash_completion \ 192 | && mv bash_completion /usr/share/bash-completion/ 193 | 194 | COPY docker-entrypoint.sh /entrypoint.sh 195 | 196 | RUN chmod a+x /entrypoint.sh 197 | 198 | 199 | # need to do pip3 install and so on 200 | RUN git clone https://github.com/toneloc/stable-channels.git /home/clightning/ \ 201 | && cd /home/clightning \ 202 | && chmod -R a+rw /home/clightning \ 203 | && pip3 install -r requirements.txt \ 204 | && mv /home/clightning/stablechannels.py /home/clightning/plugin.py \ 205 | && chmod +x /home/clightning/plugin.py 206 | 207 | 208 | # lightning-cli -k plugin subcommand=start plugin=/home/clightning/plugin.py short-channel-id=838387x342x1 stable-dollar-amount=100 is-stable-receiver=False counterparty=03421a7f5cd783dd1132d96a64b2fe3f340b80ae42a098969aaf184b183aafb10d lightning-rpc-path=/home/ubuntu/.lightning/bitcoin/lightning-rpc 209 | 210 | # /usr/local/libexec/c-lightning/plugins/ 211 | 212 | 213 | # RUN git clone https://github.com/Ride-The-Lightning/c-lightning-REST.git /opt/c-lightning-rest/ \ 214 | # && cd /opt/c-lightning-rest \ 215 | # && npm install \ 216 | # && chmod -R a+rw /opt/c-lightning-rest \ 217 | # && mv /opt/c-lightning-rest/clrest.js /opt/c-lightning-rest/plugin.js 218 | 219 | VOLUME ["/home/clightning"] 220 | VOLUME ["/opt/c-lightning-rest/certs"] 221 | 222 | EXPOSE 9735 9835 8080 10000 223 | 224 | ENTRYPOINT ["/entrypoint.sh"] 225 | 226 | CMD ["lightningd"] -------------------------------------------------------------------------------- /platforms/README.md: -------------------------------------------------------------------------------- 1 | ## Platforms 2 | 3 | Various projects and code related to other devices and scripts for Stable Channels. 4 | 5 | - `bash-script-rtl`: Bash script to do stable check by and hit CLN REST API endpoints. You can run this against Polar, for example. 6 | - `ios/StableChannels`: iOS project files related to simple wallet app. 7 | - `python-cln-plugin`: Out-of-date files FYI. Just check main directory for this. 8 | - `python-rest`: Python-based RESTful API to serve Stable Channels web content. 9 | - `python-server` 10 | - `website`: Website HTML, CSS front-end only. Should upate. 11 | -------------------------------------------------------------------------------- /platforms/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, jsonify, request, send_from_directory 2 | # import subprocess 3 | # import json 4 | # from google.protobuf import json_format 5 | # import node_pb2 6 | # import primitives_pb2 7 | # import requests 8 | 9 | app = Flask(__name__, static_folder='/var/www//stable-channels/html', static_url_path='') 10 | 11 | @app.route('/', defaults={'filename': 'index.html'}) 12 | @app.route('/') 13 | def serve_static(filename): 14 | return send_from_directory(app.static_folder, filename) 15 | 16 | 17 | @app.route('/balance') 18 | def get_balance(): 19 | return jsonify({"balance": 100.00, "price":27432.12}) 20 | 21 | # @app.route('/keysend', methods=['POST']) 22 | # def keysend(): 23 | # json_data = request.json 24 | # print(json_data) 25 | 26 | # if 'destination' not in json_data or 'amount_msat' not in json_data: 27 | # return jsonify(success=False, error="Missing necessary fields"), 400 # Bad Request 28 | 29 | # if not isinstance(json_data['amount_msat'], int): 30 | # return jsonify(success=False, error="amount_msat must be an integer"), 400 31 | 32 | # destination = json_data['destination'] 33 | # amount_msat = json_data['amount_msat'] 34 | 35 | # # Construct the command as a list 36 | # cmd = ["glcli", "keysend", destination, str(amount_msat)] 37 | 38 | # working_dir = "/home/ubuntu/greenlight" 39 | 40 | # try: 41 | # # Use subprocess.run, providing the command and capturing any output 42 | # result = subprocess.run(cmd, check=True, text=True, capture_output=True, cwd=working_dir) 43 | # # Log the output for debugging purposes 44 | # print("STDOUT:", result.stdout) 45 | # print("STDERR:", result.stderr) 46 | # except subprocess.CalledProcessError as e: 47 | # print("Error occurred:", str(e)) 48 | # return jsonify(success=False, error="Failed to send keys via command line"), 500 49 | 50 | # return jsonify(success=True), 200 51 | 52 | if __name__ == '__main__': 53 | app.run(debug=True, host='0.0.0.0', port=8080) 54 | -------------------------------------------------------------------------------- /platforms/bash-script-rtl/peerstables.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | aliceMacaroon=AgELYy1saWdodG5pbmcCPlNhdCBOb3YgMDUgMjAyMiAwNTowMzoxMiBHTVQrMDAwMCAoQ29vcmRpbmF0ZWQgVW5pdmVyc2FsIFRpbWUpAAAGIBcnf+0eDYq75V0fKEN42ulqrTHPRQAJ0JY6MBTaLAV3 4 | 5 | bobMacaroon=AgELYy1saWdodG5pbmcCPlNhdCBOb3YgMDUgMjAyMiAwNTowMzoxMyBHTVQrMDAwMCAoQ29vcmRpbmF0ZWQgVW5pdmVyc2FsIFRpbWUpAAAGIJIngPnzHfCi2+Cv8v0Iz6o6xYDseOA6G41Op+I4+wEg 6 | 7 | echo " 8 | ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⣤⣴⣶⣶⣿⣿⣿⣿⣿⣿⣿⣿⣶⣶⣶⣤⣄⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 9 | ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 10 | ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣤⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 11 | ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 12 | ⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀ 13 | ⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠉⠛⠛⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀ 14 | ⠀⠀⠀⠀⠀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⠀⠀⢰⣿⣿⠇⠀⠉⠉⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⠀⠀⠀⠀⠀ 15 | ⠀⠀⠀⢀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠉⠉⠛⠛⠿⠿⡏⠀⠀⠀⣾⣿⡿⠀⠀⠀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀⠀⠀⠀ 16 | ⠀⠀⢀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠛⠃⠀⠀⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀⠀⠀ 17 | ⠀⠀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⠀⠀ 18 | ⠀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠀⠀⠀⠀⠀⢠⣶⣦⣤⣀⡀⠀⠀⠀⠀⠀⠀⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣇⠀ 19 | ⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⣷⡄⠀⠀⠀⠀⠀⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄ 20 | ⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⡿⠀⠀⠀⠀⠀⠀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ 21 | ⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⠀⠀⠀⠀⠀⠘⠿⠿⢿⣿⣿⡿⠟⠁⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ 22 | ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ 23 | ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⠀⠀⠀⠀⠀⣴⣶⣤⣤⣀⡀⠀⠀⠀⠀⠀⠀⠐⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ 24 | ⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⣿⣿⣷⣦⡀⠀⠀⠀⠀⠀⠘⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ 25 | ⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⢿⡿⠁⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣧⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ 26 | ⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃ 27 | ⠀⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⣄⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠉⠉⠉⠉⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠀ 28 | ⠀⠀⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⠀⠀⠀⢀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠀⠀ 29 | ⠀⠀⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠀⠀⠀⣾⣿⣿⠀⠀⠀⢠⣤⣄⣀⣀⣀⣀⣤⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠁⠀⠀ 30 | ⠀⠀⠀⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⠀⠀⢰⣿⣿⡇⠀⠀⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠁⠀⠀⠀ 31 | ⠀⠀⠀⠀⠀⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣦⣾⣿⣿⡀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠀⠀⠀⠀⠀ 32 | ⠀⠀⠀⠀⠀⠀⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋⠀⠀⠀⠀⠀⠀ 33 | ⠀⠀⠀⠀⠀⠀⠀⠀⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋⠀⠀⠀⠀⠀⠀⠀⠀ 34 | ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 35 | ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 36 | ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⠋⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 37 | ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠙⠛⠻⠿⠿⢿⣿⣿⣿⣿⣿⣿⡿⠿⠿⠟⠛⠋⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀                                                         38 | " 39 | function getAliceID() { 40 | aliceID=$(curl -s -H "macaroon:$aliceMacaroon" http://127.0.0.1:8181/v1/getinfo | jq -r '.id'); 41 | } 42 | 43 | 44 | function getBobID() { 45 | bobID=$(curl -s -H "macaroon:$bobMacaroon" http://127.0.0.1:8182/v1/getinfo | jq -r '.id'); 46 | } 47 | 48 | function getAliceBalance() { 49 | aliceBalance=$(curl -s -H "macaroon:$aliceMacaroon" http://127.0.0.1:8181/v1/channel/listchannels | jq '.[0].msatoshi_to_us') 50 | } 51 | 52 | function getBobBalance() { 53 | bobBalance=$(curl -s -H "macaroon:$bobMacaroon" http://127.0.0.1:8182/v1/channel/listchannels | jq '.[0].msatoshi_to_us') 54 | } 55 | 56 | function getAliceInvoice() { 57 | # echo "in Alice's invoice function" 58 | timestampLabel=$(date +%s) 59 | amt=$1 60 | # echo $amt 61 | aliceInvoice=$(curl -s -X POST http://127.0.0.1:8181/v1/invoice/genInvoice -H "macaroon:$aliceMacaroon" -H "Content-Type: application/json" -d '{"amount":"'$amt'","label":"'$timestampLabel'","description":"booyakasha"}'  | jq -r '.bolt11'); 62 | # echo $aliceInvoice 63 | 64 | } 65 | 66 | function getBobInvoice() { 67 | # echo "in Bob's invoice function" 68 | timestampLabel=$(date +%s) 69 | # echo $timestampLabel 70 | amt=$1 71 | # echo $amt 72 | bobInvoice=$(curl -s -X POST http://127.0.0.1:8182/v1/invoice/genInvoice -H "macaroon:$bobMacaroon" -H "Content-Type: application/json" -d '{"amount":"'$amt'","label":"'$timestampLabel'","description":"booyakasha"}' | jq -r '.bolt11'); 73 | } 74 | 75 | function alicePays() { 76 | # echo "in Alice's pay function" 77 | bobInvoice=$1 78 | # echo "bobInvoice" 79 | # echo $bobInvoice 80 | curl -s -X POST http://127.0.0.1:8181/v1/pay -H "macaroon:$aliceMacaroon" -H "Content-Type: application/json" -d '{"invoice":"'$bobInvoice'"}' | jq 81 | } 82 | 83 | function bobPays() { 84 | # echo "in Bob's pay function" 85 | aliceInvoice=$1 86 | # echo "aliceInvoice" 87 | # echo $aliceInvoice 88 | curl -s -X POST http://127.0.0.1:8182/v1/pay -H "macaroon:$bobMacaroon" -H "Content-Type: application/json" -d '{"invoice":"'$aliceInvoice'"}' | jq 89 | } 90 | 91 | 92 | function getPrice() { 93 | curl -s -A "Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/81.0" https://river.com/bitcoin-price > scratch.txt; 94 | tr -d '' < scratch.txt > scratch2.txt 95 | # interim=$(grep -E -o "p class=\"js-nav-price c-home__bitcoin-price--price\".{0,12}" scratch2.txt); 96 | price=$(sed '128!d' scratch2.txt); 97 | # price=${interim:53}; 98 | echo "price" 99 | echo $price 100 | 101 | now=$(date) 102 |     103 | } 104 | 105 | getAliceID 106 | getBobID 107 | sleep 2 108 | printf "

Welcome to peerstables.org: peer-to-peer stable channels

" >> /opt/homebrew/var/www/index.html 109 | echo "
" >> /opt/homebrew/var/www/index.html 110 | printf "starting peer stable server ... " >> /opt/homebrew/var/www/index.html 111 | sleep 2 112 | printf " started ...  " >> /opt/homebrew/var/www/index.html 113 | echo "
" >> /opt/homebrew/var/www/index.html 114 | sleep 2 115 | printf "generating new at-will peer stable agreement for \$50...  " >> /opt/homebrew/var/www/index.html 116 | echo "
" >> /opt/homebrew/var/www/index.html 117 | sleep 3 118 | printf "two counterparties:" >> /opt/homebrew/var/www/index.html 119 | echo "
" >> /opt/homebrew/var/www/index.html 120 | printf " stableReceiver is Alice = $aliceID" >> /opt/homebrew/var/www/index.html 121 | echo "
" >> /opt/homebrew/var/www/index.html 122 | printf " stableProvider is Bob = $bobID" >> /opt/homebrew/var/www/index.html 123 | echo "
" >> /opt/homebrew/var/www/index.html 124 | sleep 3 125 | 126 | printf "peers connected ..." >> /opt/homebrew/var/www/index.html 127 | sleep 2 128 | printf " channel established ..." >> /opt/homebrew/var/www/index.html 129 | echo "
" >> /opt/homebrew/var/www/index.html 130 | sleep 2 131 | 132 | 133 | printf " entering stable mode, buckle up!" >> /opt/homebrew/var/www/index.html 134 | echo "

" >> /opt/homebrew/var/www/index.html 135 | 136 | for i in {1..500} 137 | 138 | do 139 | now=$(date) 140 | 141 | getAliceBalance 142 | getBobBalance 143 | getPrice 144 | # echo "stableReceiver Alice balance = $aliceBalance " 145 | # echo "stableProvider Bob balance = $bobBalance " 146 |         echo "Round $i  " >> /opt/homebrew/var/www/index.html 147 | echo "current bitcoin price is $price" >> /opt/homebrew/var/www/index.html 148 | echo "
" >> /opt/homebrew/var/www/index.html 149 | 150 | sleep 3 151 | 152 | price2=$(echo $price | sed 's/,//') 153 | price2="${price2:1}" 154 | echo "price2" 155 | echo $price2 156 | aliceBalanceBtc=$(echo "scale=8;$aliceBalance/100000000000" | bc) 157 | 158 | # echo "Alice stableReceiver bitcoin balance: $aliceBalanceBtc" 159 | 160 | 161 | expectedDollarAmount=50.00 162 | echo " expectedDollarAmount = $expectedDollarAmount" >> /opt/homebrew/var/www/index.html 163 | echo "
" >> /opt/homebrew/var/www/index.html 164 | sleep 2 165 | actualDollarAmount=$(echo "scale=2;$aliceBalanceBtc*$price2" | bc) 166 | 167 | echo " actualDollarAmount = $actualDollarAmount " >> /opt/homebrew/var/www/index.html 168 | echo "
" >> /opt/homebrew/var/www/index.html 169 | sleep 2 170 | 171 | if (( $(echo "$actualDollarAmount < $expectedDollarAmount" | bc -l) )); then 172 |  echo " actualDollarAmount less than expectedDollarAmount:" >> /opt/homebrew/var/www/index.html 173 |  echo "
" >> /opt/homebrew/var/www/index.html 174 |  echo " Bob needs to pay Alice." >> /opt/homebrew/var/www/index.html 175 |  echo "
" >> /opt/homebrew/var/www/index.html 176 |   177 |  needToPayDollarAmount=$(echo "$expectedDollarAmount - $actualDollarAmount" | bc -l) 178 |  needToPayBitcoinAmount=$(echo "$needToPayDollarAmount / $price2" | bc -l) 179 |  echo " Bob needs to pay this much in dollars: $needToPayDollarAmount" >> /opt/homebrew/var/www/index.html 180 |  echo "
" >> /opt/homebrew/var/www/index.html 181 |  echo " Bob needs to pay this much in bitcoin: $needToPayBitcoinAmount" >> /opt/homebrew/var/www/index.html 182 |  echo "
" >> /opt/homebrew/var/www/index.html 183 |  # echo $needToPayBitcoinAmount 184 |  needToPayBitcoinAmount="${needToPayBitcoinAmount:1}" 185 |  needToPayBitcoinAmount="${needToPayBitcoinAmount%?????????}" 186 |  getAliceInvoice $needToPayBitcoinAmount 187 |  sleep 5 188 |  # Pay alice what is needed 189 |   190 |  echo " Bob is paying now." >> /opt/homebrew/var/www/index.html 191 |  echo "
" >> /opt/homebrew/var/www/index.html 192 |   193 |   194 |  bobPays $aliceInvoice 195 | fi 196 | 197 | if (( $(echo "$actualDollarAmount == $expectedDollarAmount" | bc -l) )); then 198 |  echo " actualDollarAmount equals expectedDollarAmount: " >> /opt/homebrew/var/www/index.html 199 |  echo "
" >> /opt/homebrew/var/www/index.html 200 |  echo " No payment needed." >> /opt/homebrew/var/www/index.html 201 |  echo "
" >> /opt/homebrew/var/www/index.html 202 | 203 | fi 204 | 205 | if (( $(echo "$actualDollarAmount > $expectedDollarAmount" | bc -l) )); then 206 |  echo " actualDollarAmount more than expectedDollarAmount: " >> /opt/homebrew/var/www/index.html 207 |  echo "
" >> /opt/homebrew/var/www/index.html 208 |  echo " Alice needs to pay Bob. " >> /opt/homebrew/var/www/index.html 209 |  echo "
" >> /opt/homebrew/var/www/index.html 210 |  needToPayDollarAmount=$(echo "$actualDollarAmount - $expectedDollarAmount" | bc -l) 211 |  echo " Alice needs to pay this much in dollars: $needToPayDollarAmount " >> /opt/homebrew/var/www/index.html 212 |  echo "
" >> /opt/homebrew/var/www/index.html 213 |  needToPayBitcoinAmount=$(echo "$needToPayDollarAmount / $price2" | bc -l) 214 |  echo " lice needs to pay this much in bitcoin: $needToPayBitcoinAmount" >> /opt/homebrew/var/www/index.html 215 |  echo "
" >> /opt/homebrew/var/www/index.html 216 |  needToPayBitcoinAmount="${needToPayBitcoinAmount:1}" 217 |  needToPayBitcoinAmount="${needToPayBitcoinAmount%?????????}" 218 | 219 |  getBobInvoice $needToPayBitcoinAmount 220 | 221 |  # echo "bobInvoice" 222 |  # echo $bobInvoice 223 | 224 |  sleep 5 225 |  # Pay bob what is needed 226 |   227 |  echo " Alice is paying now.:" >> /opt/homebrew/var/www/index.html 228 |   229 |   230 |  alicePays $bobInvoice 231 | fi 232 | 233 |         234 | 235 |         echo " payment complete!" >> /opt/homebrew/var/www/index.html 236 |         echo "
" >> /opt/homebrew/var/www/index.html 237 |         echo "
" >> /opt/homebrew/var/www/index.html 238 | printf "waiting 30 seconds until next price query ..." >> /opt/homebrew/var/www/index.html 239 | sleep 10 240 | echo "
..." >> /opt/homebrew/var/www/index.html 241 | sleep 10 242 | echo "
..." >> /opt/homebrew/var/www/index.html 243 |         sleep 10 244 | echo "
" >> /opt/homebrew/var/www/index.html 245 |     done 246 | 247 | -------------------------------------------------------------------------------- /platforms/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { Image, StyleSheet, Platform, TouchableOpacity, Text, View, Animated } from 'react-native'; 3 | 4 | import { HelloWave } from '@/components/HelloWave'; 5 | import ParallaxScrollView from '@/components/ParallaxScrollView'; 6 | import { ThemedText } from '@/components/ThemedText'; 7 | import { ThemedView } from '@/components/ThemedView'; 8 | import {Builder, Config, ChannelConfig, Node} from 'ldk-node-rn'; 9 | import {ChannelDetails, NetAddress, LogLevel} from 'ldk-node-rn/lib/classes/Bindings'; 10 | import RNFS from 'react-native-fs'; 11 | import {addressToString} from 'ldk-node-rn/lib/utils'; 12 | 13 | let docDir = RNFS.DocumentDirectoryPath + '/NEW_LDK_NODE/' + `${Platform.Version}/`; 14 | console.log('Platform Version=====>', `${Platform.Version}`); 15 | 16 | 17 | export default function HomeScreen() { 18 | const [message, setMessage] = useState(''); 19 | const scaleAnim = useRef(new Animated.Value(1)).current; 20 | 21 | useEffect(() => { 22 | Animated.loop( 23 | Animated.sequence([ 24 | Animated.timing(scaleAnim, { 25 | toValue: 1.05, 26 | duration: 1000, 27 | useNativeDriver: true, 28 | }), 29 | Animated.timing(scaleAnim, { 30 | toValue: 1, 31 | duration: 1000, 32 | useNativeDriver: true, 33 | }), 34 | ]) 35 | ).start(); 36 | }, [scaleAnim]); 37 | 38 | const handlePress = async () => { 39 | console.log("here"); 40 | const mnemonic = 'absurd aware donate anxiety gather lottery advice document advice choice limb balance'; 41 | 42 | const buildNode = async (mnemonic: string) => { 43 | let host; 44 | let port = 39735; 45 | let esploraServer; 46 | 47 | host = '0.0.0.0'; 48 | if (Platform.OS === 'android') { 49 | host = '0.0.0.0'; 50 | } else if (Platform.OS === 'ios') { 51 | host = '0.0.0.0'; 52 | } 53 | 54 | esploraServer = `https://mutinynet.ltbl.io/api`; 55 | console.log("here2"); 56 | try { 57 | let docDir = RNFS.DocumentDirectoryPath + '/NEW_LDK_NODE/' + `${Platform.Version}/`; 58 | const storagePath = docDir; 59 | console.log('storagePath====>', storagePath); 60 | 61 | console.log("here3"); 62 | console.log('storagePath====>', storagePath); 63 | 64 | const ldkPort = Platform.OS === 'ios' ? (Platform.Version == '17.0' ? 2000 : 2001) : 8081; 65 | const config = await new Config().create(storagePath, docDir + 'logs', 'signet', [new NetAddress(host, ldkPort)]); 66 | const builder = await new Builder().fromConfig(config); 67 | await builder.setNetwork('signet'); 68 | await builder.setEsploraServer(esploraServer); 69 | const key = await builder.setEntropyBip39Mnemonic(mnemonic); 70 | console.log('---Key--- ', key); 71 | await builder.setLiquiditySourceLsps2('44.219.111.31:39735', '0371d6fd7d75de2d0372d03ea00e8bacdacb50c27d0eaea0a76a0622eff1f5ef2b', 'JZWN9YLW'); 72 | 73 | const nodeObj: Node = await builder.build(); 74 | 75 | const started = await nodeObj.start(); 76 | if (started) { 77 | console.log('Node started successfully'); 78 | } else { 79 | console.log('Node failed to start'); 80 | } 81 | 82 | const nodeId = await nodeObj.nodeId(); 83 | const listeningAddr = await nodeObj.listeningAddresses(); 84 | console.log('Node Info:', { nodeId: nodeId.keyHex, listeningAddress: `${listeningAddr?.map(i => addressToString(i))}` }); 85 | } catch (e) { 86 | console.error('Error in starting and building Node:', e); 87 | } 88 | }; 89 | 90 | await buildNode(mnemonic); 91 | }; 92 | return ( 93 | 100 | }> 101 | 102 | Stablecorn 103 | 104 | 105 | 106 | Step 1: Get a Lightning invoice ⚡ 107 | 108 | Press the "Stabilize" button below. 109 | 110 | 111 | 112 | Step 2: Send yourself bitcoin. 💸 113 | 114 | You can do this from another app or your account on Coinbase or Binance. 115 | 116 | 117 | 118 | Step 3: Stability activated 🔧 119 | 120 | Your keys, your bitcoin, your tools. 121 | 122 | 123 | 124 | 125 | handlePress()}> 126 | Stabilize 127 | 128 | 129 | 130 | {message !== '' && ( 131 | 132 | {message} 133 | 134 | )} 135 | 136 | ); 137 | } 138 | 139 | const styles = StyleSheet.create({ 140 | titleContainer: { 141 | flexDirection: 'row', 142 | alignItems: 'center', 143 | gap: 8, 144 | }, 145 | stepContainer: { 146 | gap: 8, 147 | marginBottom: 8, 148 | }, 149 | reactLogo: { 150 | height: '100%', 151 | width: '100%', 152 | resizeMode: 'stretch', 153 | }, 154 | buttonContainer: { 155 | marginTop: 40, // Adjust this value to place the button lower 156 | marginBottom: 20, 157 | marginHorizontal: 20, 158 | }, 159 | button: { 160 | backgroundColor: '#4CAF50', // Green color for the button 161 | paddingVertical: 15, 162 | paddingHorizontal: 20, 163 | borderRadius: 25, // Rounded corners 164 | alignItems: 'center', 165 | }, 166 | buttonText: { 167 | color: '#fff', 168 | fontSize: 18, // Bigger text 169 | fontWeight: 'bold', 170 | }, 171 | messageContainer: { 172 | marginTop: 20, 173 | alignItems: 'center', 174 | }, 175 | }); 176 | -------------------------------------------------------------------------------- /platforms/mnemonic-tool.py: -------------------------------------------------------------------------------- 1 | import bip39 2 | import secrets # Make sure to use cryptographically sound randomness 3 | 4 | # Generate 128 bits of randomness for a 12-word mnemonic phrase 5 | rand_12 = secrets.randbits(128).to_bytes(16, 'big') # 16 bytes of randomness 6 | phrase_12 = bip39.encode_bytes(rand_12) 7 | 8 | # Generate 256 bits of randomness for a 24-word mnemonic phrase 9 | rand_24 = secrets.randbits(256).to_bytes(32, 'big') # 32 bytes of randomness 10 | phrase_24 = bip39.encode_bytes(rand_24) 11 | 12 | print("Mnemonic Phrase (12 words):", phrase_12) 13 | 14 | seed_12 = bip39.phrase_to_seed(phrase_12)[:32] # Only need 32 bytes 15 | print("Seed (from 12 words):", seed_12.hex()) 16 | 17 | print("Mnemonic Phrase (24 words):", phrase_24) 18 | 19 | seed_24 = bip39.phrase_to_seed(phrase_24)[:32] # Only need 32 bytes 20 | print("Seed (from 24 words):", seed_24.hex()) 21 | -------------------------------------------------------------------------------- /platforms/python-cln-plugin/requirements.txt: -------------------------------------------------------------------------------- 1 | pyln-client 2 | cachetools 3 | requests 4 | statistics 5 | apscheduler -------------------------------------------------------------------------------- /platforms/python-cln-plugin/stablechannels.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | # PeerStables: p2p BTCUSD trading on Lightning 4 | 5 | from pyln.client import Plugin 6 | from collections import namedtuple 7 | from pyln.client import Millisatoshi 8 | from cachetools import cached, TTLCache 9 | from requests.adapters import HTTPAdapter 10 | from requests.packages.urllib3.util.retry import Retry 11 | import requests 12 | import statistics 13 | from pyln.client import LightningRpc 14 | import time 15 | from datetime import datetime 16 | from apscheduler.schedulers.blocking import BlockingScheduler 17 | import threading 18 | 19 | plugin = Plugin() 20 | 21 | class StableChannel: 22 | def __init__( 23 | self, 24 | plugin: Plugin, 25 | short_channel_id: str, 26 | expected_dollar_amount: float, 27 | minimum_margin_ratio: float, 28 | is_stable_receiver: bool, 29 | counterparty: str, 30 | lightning_rpc_path: str, 31 | our_balance: float, 32 | their_balance: float, 33 | risk_score: int, 34 | stable_receiver_dollar_amount: float, 35 | stable_provider_dollar_amount: float, 36 | timestamp: int, 37 | formatted_datetime: str, 38 | payment_made: bool 39 | ): 40 | self.plugin = plugin 41 | self.short_channel_id = short_channel_id 42 | self.expected_dollar_amount = expected_dollar_amount 43 | self.minimum_margin_ratio = minimum_margin_ratio 44 | self.is_stable_receiver = is_stable_receiver 45 | self.counterparty = counterparty 46 | self.lightning_rpc_path = lightning_rpc_path 47 | self.our_balance = our_balance 48 | self.their_balance = their_balance 49 | self.risk_score = risk_score 50 | self.stable_receiver_dollar_amount = stable_receiver_dollar_amount 51 | self.stable_provider_dollar_amount = stable_provider_dollar_amount 52 | self.timestamp = timestamp 53 | self.formatted_datetime = datetime 54 | self.payment_made = payment_made 55 | 56 | Source = namedtuple('Source', ['name', 'urlformat', 'replymembers']) 57 | 58 | # 5 price feed sources 59 | sources = [ 60 | # e.g. {"high": "18502.56", "last": "17970.41", "timestamp": "1607650787", "bid": "17961.87", "vwap": "18223.42", "volume": "7055.63066541", "low": "17815.92", "ask": "17970.41", "open": "18250.30"} 61 | Source('bitstamp', 62 | 'https://www.bitstamp.net/api/v2/ticker/btc{currency_lc}/', 63 | ['last']), 64 | # e.g. {"bitcoin":{"usd":17885.84}} 65 | Source('coingecko', 66 | 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies={currency_lc}', 67 | ['bitcoin', '{currency_lc}']), 68 | # e.g. {"time":{"updated":"Dec 16, 2020 00:58:00 UTC","updatedISO":"2020-12-16T00:58:00+00:00","updateduk":"Dec 16, 2020 at 00:58 GMT"},"disclaimer":"This data was produced from the CoinDesk Bitcoin Price Index (USD). Non-USD currency data converted using hourly conversion rate from openexchangerates.org","bpi":{"USD":{"code":"USD","rate":"19,395.1400","description":"United States Dollar","rate_float":19395.14},"AUD":{"code":"AUD","rate":"25,663.5329","description":"Australian Dollar","rate_float":25663.5329}}} 69 | Source('coindesk', 70 | 'https://api.coindesk.com/v1/bpi/currentprice/{currency}.json', 71 | ['bpi', '{currency}', 'rate_float']), 72 | # e.g. {"data":{"base":"BTC","currency":"USD","amount":"19414.63"}} 73 | Source('coinbase', 74 | 'https://api.coinbase.com/v2/prices/spot?currency={currency}', 75 | ['data', 'amount']), 76 | # e.g. { "USD" : {"15m" : 6650.3, "last" : 6650.3, "buy" : 6650.3, "sell" : 6650.3, "symbol" : "$"}, "AUD" : {"15m" : 10857.19, "last" : 10857.19, "buy" : 10857.19, "sell" : 10857.19, "symbol" : "$"},... 77 | Source('blockchain.info', 78 | 'https://blockchain.info/ticker', 79 | ['{currency}', 'last']), 80 | ] 81 | 82 | # Request logic is from "currencyrate" plugin: 83 | # https://github.com/lightningd/plugins/blob/master/currencyrate 84 | def requests_retry_session( 85 | retries=3, 86 | backoff_factor=0.3, 87 | status_forcelist=(500, 502, 504), 88 | session=None, 89 | ): 90 | session = session or requests.Session() 91 | retry = Retry( 92 | total=retries, 93 | read=retries, 94 | connect=retries, 95 | backoff_factor=backoff_factor, 96 | status_forcelist=status_forcelist, 97 | ) 98 | adapter = HTTPAdapter(max_retries=retry) 99 | session.mount('http://', adapter) 100 | session.mount('https://', adapter) 101 | return session 102 | 103 | def get_currencyrate(plugin, currency, urlformat, replymembers): 104 | # NOTE: Bitstamp has a DNS/Proxy issues that can return 404 105 | # Workaround: retry up to 5 times with a delay 106 | currency_lc = currency.lower() 107 | url = urlformat.format(currency_lc=currency_lc, currency=currency) 108 | r = requests_retry_session(retries=5, status_forcelist=[404]).get(url, proxies=plugin.proxies) 109 | 110 | if r.status_code != 200: 111 | plugin.log(level='info', message='{}: bad response {}'.format(url, r.status_code)) 112 | return None 113 | 114 | json = r.json() 115 | for m in replymembers: 116 | expanded = m.format(currency_lc=currency_lc, currency=currency) 117 | if expanded not in json: 118 | plugin.log(level='debug', message='{}: {} not in {}'.format(url, expanded, json)) 119 | return None 120 | json = json[expanded] 121 | 122 | try: 123 | return Millisatoshi(int(10**11 / float(json))) 124 | except Exception: 125 | plugin.log(level='info', message='{}: could not convert {} to msat'.format(url, json)) 126 | return None 127 | 128 | def set_proxies(plugin): 129 | config = plugin.rpc.listconfigs() 130 | if 'always-use-proxy' in config and config['always-use-proxy']: 131 | paddr = config['proxy'] 132 | # Default port in 9050 133 | if ':' not in paddr: 134 | paddr += ':9050' 135 | plugin.proxies = {'https': 'socks5h://' + paddr, 136 | 'http': 'socks5h://' + paddr} 137 | else: 138 | plugin.proxies = None 139 | 140 | # Don't grab these more than once per minute. 141 | @cached(cache=TTLCache(maxsize=1024, ttl=60)) 142 | def get_rates(plugin, currency): 143 | rates = {} 144 | for s in sources: 145 | r = get_currencyrate(plugin, currency, s.urlformat, s.replymembers) 146 | if r is not None: 147 | rates[s.name] = r 148 | 149 | print("rates line 165",rates) 150 | return rates 151 | 152 | @plugin.method("currencyconvert") 153 | def currencyconvert(plugin, amount, currency): 154 | """Converts currency using given APIs.""" 155 | rates = get_rates(plugin, currency.upper()) 156 | if len(rates) == 0: 157 | raise Exception("No values available for currency {}".format(currency.upper())) 158 | 159 | val = statistics.median([m.millisatoshis for m in rates.values()]) * float(amount) 160 | 161 | estimated_price = "{:.2f}".format(100000000000 / statistics.median([m.millisatoshis for m in rates.values()])) 162 | 163 | return ({"msat": Millisatoshi(round(val))}, estimated_price) 164 | 165 | def start_scheduler(sc): 166 | # Now, enter into regularly scheduled programming 167 | # Schedule the check balances every 1 minute 168 | scheduler = BlockingScheduler() 169 | scheduler.add_job(check_stables, 'cron', minute='0/5', args=[sc]) 170 | scheduler.start() 171 | 172 | # 5 scenarios to handle 173 | # Scenario 1 - Difference to small to worry about = do nothing 174 | # Scenario 2 - Node is stableReceiver and needs to get paid = wait 60 seconds; check on payment 175 | # Scenario 3 - Node is stableProvider and needs to pay = keysend 176 | # Scenario 4 - Node is stableReceiver and needs to pay = keysend 177 | # Scenario 5 - Node is stableProvider and expects to get paid 178 | # "sc" = "Stable Channel" object 179 | def check_stables(sc): 180 | l1 = LightningRpc(sc.lightning_rpc_path) 181 | 182 | msat_dict, estimated_price = currencyconvert(plugin, sc.expected_dollar_amount, "USD") 183 | 184 | expected_msats = msat_dict["msat"] 185 | 186 | # Ensure we are connected 187 | list_funds_data = l1.listfunds() 188 | channels = list_funds_data.get("channels", []) 189 | 190 | # Find the correct stable channel 191 | for channel in channels: 192 | if channel.get("short_channel_id") == sc.short_channel_id: 193 | sc.our_balance = channel.get("our_amount_msat") 194 | sc.their_balance = Millisatoshi.__sub__(channel.get("amount_msat"), sc.our_balance) 195 | 196 | # Get Stable Receiver dollar amount 197 | if sc.is_stable_receiver: 198 | sc.stable_receiver_dollar_amount = round((int(sc.our_balance) * sc.expected_dollar_amount) / int(expected_msats), 3) 199 | else: 200 | sc.stable_receiver_dollar_amount = round((int(sc.their_balance) * sc.expected_dollar_amount) / int(expected_msats), 3) 201 | 202 | formatted_time = datetime.utcnow().strftime("%H:%M %d %b %Y") 203 | 204 | sc.payment_made = False 205 | amount_too_small = False 206 | 207 | # 1 - Difference to small to worry about = do nothing 208 | if abs(sc.expected_dollar_amount - float(sc.stable_receiver_dollar_amount)) < 0.01: 209 | amount_too_small = True 210 | else: 211 | # Round to nearest msat 212 | if sc.is_stable_receiver: 213 | may_need_to_pay_amount = round(abs(int(expected_msats) - int(sc.our_balance))) 214 | else: 215 | may_need_to_pay_amount = round(abs(int(expected_msats) - int(sc.their_balance))) 216 | 217 | # USD price went down. 218 | if not amount_too_small and (sc.stable_receiver_dollar_amount < sc.expected_dollar_amount): 219 | # Scenario 2 - Node is stableReceiver and needs to get paid 220 | # Wait 30 seconds 221 | if sc.is_stable_receiver: 222 | time.sleep(30) 223 | 224 | list_funds_data = l1.listfunds() 225 | 226 | # We should have payment now; check that amount is within 1 penny 227 | channels = list_funds_data.get("channels", []) 228 | print(channels) 229 | 230 | for channel in channels: 231 | if channel.get("short_channel_id") == sc.short_channel_id: 232 | new_our_balance = channel.get("our_amount_msat") 233 | 234 | new_stable_receiver_dollar_amount = round((int(new_our_balance) * sc.expected_dollar_amount) / int(expected_msats), 3) 235 | print("2,",str(new_stable_receiver_dollar_amount)) 236 | 237 | if sc.expected_dollar_amount - float(new_stable_receiver_dollar_amount) < 0.01: 238 | sc.payment_made = True 239 | else: 240 | # Risk score. Increase risk score 241 | sc.risk_score = sc.risk_score + 1 242 | 243 | 244 | elif not(sc.is_stable_receiver): 245 | # 3 - Node is stableProvider and needs to pay = keysend 246 | result = l1.keysend(sc.counterparty,may_need_to_pay_amount) 247 | 248 | # TODO - error handling 249 | sc.payment_made = True 250 | 251 | elif amount_too_small: 252 | sc.payment_made = False 253 | 254 | # USD price went up 255 | # TODO why isnt expected_dollar_amount being a float? 256 | elif not amount_too_small and sc.stable_receiver_dollar_amount > sc.expected_dollar_amount: 257 | # 4 - Node is stableReceiver and needs to pay = keysend 258 | if sc.is_stable_receiver: 259 | result = l1.keysend(sc.counterparty,may_need_to_pay_amount) 260 | 261 | # TODO - error handling 262 | sc.payment_made = True 263 | 264 | # Scenario 5 - Node is stableProvider and expects to get paid 265 | elif not(sc.is_stable_receiver): 266 | time.sleep(30) 267 | 268 | list_funds_data = l1.listfunds() 269 | 270 | channels = list_funds_data.get("channels", []) 271 | print(channels) 272 | 273 | for channel in channels: 274 | if channel.get("short_channel_id") == sc.short_channel_id: 275 | 276 | # We should have payment now; check amount is within 1 penny 277 | new_our_balance = channel.get("our_amount_msat") 278 | new_their_balance = Millisatoshi.__sub__(channel.get("amount_msat"), new_our_balance) 279 | 280 | new_stable_receiver_dollar_amount = round((int(new_their_balance) * sc.expected_dollar_amount) / int(expected_msats), 3) 281 | print("5,",str(new_stable_receiver_dollar_amount)) 282 | 283 | if sc.expected_dollar_amount - float(new_stable_receiver_dollar_amount) < 0.01: 284 | sc.payment_made = True 285 | else: 286 | # Risk score. Increase risk score 287 | sc.risk_score = sc.risk_score + 1 288 | 289 | json_line = f'{{"formatted_time": "{formatted_time}", "estimated_price": {estimated_price}, "expected_dollar_amount": {sc.expected_dollar_amount}, "stable_receiver_dollar_amount": {sc.stable_receiver_dollar_amount}, "payment_made": {sc.payment_made}, "risk_score": {sc.risk_score}}},\n' 290 | 291 | # Log the result 292 | if sc.is_stable_receiver: 293 | file_path = '/home/ubuntu/stablelog1.json' 294 | 295 | with open(file_path, 'a') as file: 296 | file.write(json_line) 297 | 298 | elif not(sc.is_stable_receiver): 299 | file_path = '/home/ubuntu/stablelog2.json' 300 | 301 | with open(file_path, 'a') as file: 302 | file.write(json_line) 303 | 304 | @plugin.init() 305 | def init(options, configuration, plugin): 306 | print("here") 307 | set_proxies(plugin) 308 | stable_details = options['stable-details'] 309 | 310 | print(str(stable_details)) 311 | 312 | # TODO - Pass in as plugin start args 313 | if stable_details != ['']: 314 | for s in stable_details: 315 | parts = s.split(',') 316 | 317 | if len(parts) != 6: 318 | raise Exception("Too few or too many Stable Channel paramaters at start.") 319 | 320 | if parts[3] == "False": 321 | is_stable_receiver = False 322 | elif parts[3] == "True": 323 | is_stable_receiver = True 324 | 325 | sc = StableChannel( 326 | plugin=plugin, 327 | short_channel_id=parts[0], 328 | expected_dollar_amount=float(parts[1]), 329 | minimum_margin_ratio=float(parts[2]), 330 | is_stable_receiver=is_stable_receiver, 331 | counterparty=parts[4], 332 | lightning_rpc_path=parts[5], 333 | our_balance=0, 334 | their_balance=0, 335 | risk_score=0, 336 | stable_receiver_dollar_amount=0, 337 | stable_provider_dollar_amount=0, 338 | timestamp=0, 339 | formatted_datetime='', 340 | payment_made=False 341 | ) 342 | 343 | # Let lightningd sync up before starting the stable tests 344 | # time.sleep(10) 345 | 346 | # need to start a new thread so init funciotn can return 347 | threading.Thread(target=start_scheduler, args=(sc,)).start() 348 | 349 | plugin.add_option(name='stable-details', default='', description='Input stable details.') 350 | 351 | # This has an effect only for recent pyln versions (0.9.3+). 352 | plugin.options['stable-details']['multi'] = True 353 | 354 | plugin.run() 355 | 356 | 357 | -------------------------------------------------------------------------------- /platforms/python-rest/stablechannels.py: -------------------------------------------------------------------------------- 1 | import sys      # for taking in command line arguments 2 | import requests # for http requests 3 | import time     # for timestamps 4 | import json     # for handling json 5 | 6 | # populate in-memory variables from command line 7 | channel_id = sys.argv[1] 8 | our_macaroon = sys.argv[2] 9 | is_stable_receiver = sys.argv[3] 10 | expected_dollar_amount = float(sys.argv[4]) 11 | 12 | # initialize other static variables 13 | headers = {'macaroon':our_macaroon} 14 | base_URL = 'http://127.0.0.1:8183' 15 | endpoint_get_info = '/v1/getinfo' 16 | endpoint_list_channels = '/v1/channel/listchannels' 17 | endpoint_gen_invoice='/v1/invoice/genInvoice' 18 | endpoint_pay_invoice='/v1/pay' 19 | endpoint_keysend='/v1/pay/keysend' 20 | 21 | # changeable variables 22 | deliquency_meter = 0 23 | 24 | def get_their_node_id(): 25 |     response=requests.get(base_URL + endpoint_list_channels, headers=headers) 26 |     json_response=json.loads(str(response.text)) 27 |     for obj in json_response: 28 |         if obj.get('channel_id') == channel_id: 29 |             return obj.get('id') 30 | 31 | def get_our_balance(): 32 |     response=requests.get(base_URL + endpoint_list_channels, headers=headers) 33 |     return json.loads(str(response.text))[0].get('msatoshi_to_us') 34 | 35 | def get_their_balance(): 36 |     response=requests.get(base_URL + endpoint_list_channels, headers=headers) 37 |     return json.loads(str(response.text))[0].get('msatoshi_to_them') 38 |     39 | def check_delinquency(peer_id, is_offline, owes_money): 40 |     if stablePartner.is_offline: 41 |         deliquency_count += 1 42 |     if stablePartner.owes_money: 43 |         deliquency_count += 1 44 |     if stablePartner.deliquency_count > 4: 45 |         # too delinquent 46 |         print("Alert for peerID: " + str(peer_id)) 47 |     return deliquency_count 48 | 49 | # Binance 50 | def get_price_binance(): 51 |     response = requests.get("https://api.binance.us/api/v3/avgPrice?symbol=BTCUSDT") 52 |     return float(json.loads(str(response.text)).get("price")) 53 |   54 | # # Kraken 55 | # def get_price_kraken(): 56 | 57 | # will only pay to the stable partner ('their_node_id') 58 | def keysend(amount): 59 |     headers = {   60 |         'Content-Type': 'application/json', 61 |         'macaroon': our_macaroon 62 |     } 63 |     data = { 64 |         'pubkey': their_node_id, 65 |         'amount':amount 66 |     } 67 |     print(data) 68 |     response = requests.post(base_URL + endpoint_keysend, headers=headers, data=json.dumps(data)) 69 |     print(response.text) 70 | 71 | their_node_id = get_their_node_id(); 72 | 73 | is_in_stable_mode = True 74 | 75 | while is_in_stable_mode: 76 |     # get respective balances 77 |     our_balance = get_our_balance(); 78 |     their_balance =  get_their_balance(); 79 | 80 |     # get price and actual dollar amount 81 |     price = get_price_binance(); 82 | 83 |     # need to modify to handle both sides 84 |     actual_dollar_amount = (our_balance / 100000000000) * price 85 |     print(actual_dollar_amount) 86 | 87 |     if actual_dollar_amount < expected_dollar_amount and not(is_stable_receiver): 88 |         print("Stable Receiver needs to get paid.") 89 |         need_to_pay_amount = (expected_dollar_amount - actual_dollar_amount) * 100000000000 90 |         print("need to pay amt = " + need_to_pay_amount) 91 |         keysend(need_to_pay_amount) 92 |         break 93 | 94 |     elif actual_dollar_amount == expected_dollar_amount: 95 |         print("Juuust right") 96 | 97 |     elif actual_dollar_amount > expected_dollar_amount and is_stable_receiver: 98 |         print("Stable provider needs to get paid .") 99 |         need_to_pay_amount = round((actual_dollar_amount - expected_dollar_amount) * 100000000000) 100 |         print("need to pay amt = " + str(need_to_pay_amount)) 101 |         keysend(need_to_pay_amount) 102 |         break 103 | 104 | 105 |         # keysend(); 106 | 107 | 108 | 109 | # our_macaroon='______+0eDYq75V0fKEN42ulqrTHPRQAJ0JY6MBTaLAV3' 110 | # self_is_stable_receiver = True 111 | 112 | 113 | 114 | # def invoice_them(amount): 115 | #     global invoice 116 | 117 | #     # get timestamp 118 | #     timestamp = int(time.time()) 119 | 120 | #     headers = {   121 | #         'Content-Type': 'application/json', 122 | #         'macaroon': our_macaroon 123 | #     } 124 | 125 | #     data = { 126 | #         'amount': amount, 127 | #         'label': timestamp, 128 | #         'description': 'more info here would be perfect' 129 | #     } 130 | 131 | #     response = requests.post(base_URL + endpoint_gen_invoice, headers=headers, data=json.dumps(data)) 132 | #     json_reponse = json.loads(response.text) 133 | #     invoice = json_reponse.get('bolt11') 134 |     135 | # def pay_invoice(invoice): 136 | #     data = { 137 | #         'invoice': invoice, 138 | #     } 139 | #     headers = {'macaroon': bob_macaroon} 140 | #     print(data) 141 | #     response = requests.post(bob_URL + endpoint_pay_invoice, headers=headers, data=data) 142 | #     print(response.text) 143 | 144 | # def get_our_node_id(): 145 | #     response = requests.get(base_URL + endpoint_get_info, headers=headers) 146 | #     our_node_id=json.loads(str(response.text)).get('id') 147 | -------------------------------------------------------------------------------- /platforms/stablechannels.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dashboard 7 | 8 | 9 | 10 | 11 | 12 | 182 | 183 | 184 | 185 | 186 | 217 | 218 | 219 |
220 | 221 |
222 |
223 |

Stable Provider Dashboard

224 |

Channel ID 818502x1978x1

225 |

Last updated 01:53 05 Jan 2024 🔄

226 | Status: 227 | 228 | Active 🟢 229 | 230 | 231 |

Counterparty Risk Score: 232 | 233 | 0 🟢 234 | 235 |

236 | 237 |
238 |
239 | 240 |
241 | 242 |
243 |
244 |
245 |
246 |

Stable Channel Terms

247 |
248 |
249 | 250 | 251 | 252 | 253 | 254 |
255 |
256 |
257 |
258 |
259 |
260 |
261 |

Performance

262 |
263 |
264 | 265 | 266 | 267 | 268 | 269 |
270 |
271 |
272 |
273 |
274 | 275 | 276 |
277 |
278 |
279 |
280 |

Price Graph

281 |
282 |
283 | 284 |
285 |
286 |
287 |
288 |

289 | 290 |
291 |
292 |

Most Recent Payments

293 |
294 |
295 |
296 |
297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 |
Settlement PeriodBitcoin PriceExpected Dollar AmountStable Receiver Dollar AmountPayment Made?
311 |
312 |
313 |
314 |
315 | 316 |
317 |
318 | 319 | 536 | 537 | 538 | 539 | 540 | -------------------------------------------------------------------------------- /platforms/stablecoin-chart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Stablecoin Supply Chart 7 | 8 | 9 | 28 | 29 | 30 |
31 | 32 |
33 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /platforms/sum_payments.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | with open('stablelog1.json', 'r') as file: 4 | data = json.load(file) 5 | 6 | cumulative_sum = 0 7 | counter =0 8 | 9 | for entry in data: 10 | if entry["payment_made"]: 11 | difference = abs(entry["stable_receiver_dollar_amount"] - entry["expected_dollar_amount"]) 12 | cumulative_sum += difference 13 | counter += 1 14 | 15 | print(counter) 16 | print(cumulative_sum) 17 | 18 | -------------------------------------------------------------------------------- /platforms/update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | lightning-cli -k plugin subcommand=stop plugin=stablechannels.py 4 | 5 | cd /home/clightning 6 | 7 | git stash 8 | 9 | git pull 10 | 11 | chmod +x stablechannels.py 12 | -------------------------------------------------------------------------------- /platforms/utxoracle-cln-plugin/utxoracle.py: -------------------------------------------------------------------------------- 1 | 2 | ############################################################################### 3 | 4 | # Introduction: This is UTXOracle.py CLN Plugin 5 | 6 | ############################################################################### 7 | 8 | # This is a modfication of code found here - https://utxo.live/oracle/ - to make 9 | # it work with Core Lightning 10 | 11 | # This python CLN plugin estimates the daily USD price of bitcoin using only 12 | # your bitcoin Core full node. It will work even while you are disconnected 13 | # from the internet because it only reads blocks from your machine. It does not 14 | # save files, write cookies, or access any wallet information. It only reads 15 | # blocks, analyzes output patterns, and estimates a daily average a USD 16 | # price of bitcoin. The call to your node is the standard "bitcoin-cli". The 17 | # date and price ranges expected to work for this version are from 2020-7-26 18 | # and from $10,000 to $100,000 19 | 20 | print("UTXOracle CLN plugin version 6\n") 21 | 22 | ### Modification: additional functions 23 | 24 | def var_int(data, offset): 25 | """Extract variable length integer and its size from data starting at offset.""" 26 | first_byte = data[offset] 27 | 28 | if first_byte < 0xfd: 29 | return first_byte, 1 30 | elif first_byte == 0xfd: 31 | return int.from_bytes(data[offset + 1:offset + 3], 'little'), 3 32 | elif first_byte == 0xfe: 33 | return int.from_bytes(data[offset + 1:offset + 5], 'little'), 5 34 | else: 35 | return int.from_bytes(data[offset + 1:offset + 9], 'little'), 9 36 | 37 | def parse_transaction(data, offset, outputs): 38 | """Parse a transaction and return the offset after the transaction.""" 39 | # Start of transaction: version (4 bytes) 40 | offset += 4 41 | 42 | # Input count 43 | input_count, size = var_int(data, offset) 44 | offset += size 45 | 46 | # Parse inputs 47 | for _ in range(input_count): 48 | # TXID (32 bytes) and output index (4 bytes) 49 | offset += 36 50 | 51 | # Signature script 52 | script_length, size = var_int(data, offset) 53 | offset += size + script_length 54 | 55 | # Sequence 56 | offset += 4 57 | 58 | # Output count 59 | output_count, size = var_int(data, offset) 60 | offset += size 61 | 62 | # Parse outputs 63 | for _ in range(output_count): 64 | # Value 65 | value = int.from_bytes(data[offset:offset + 8], 'little') 66 | outputs.append(value) # Adding the value to the outputs list 67 | offset += 8 68 | 69 | # PK Script 70 | pk_script_length, size = var_int(data, offset) 71 | offset += size + pk_script_length 72 | 73 | # Lock time 74 | offset += 4 75 | 76 | return offset 77 | 78 | def get_raw_block_utxo_values(hex_block): 79 | data = bytes.fromhex(hex_block) 80 | 81 | # Skip over block header (80 bytes) 82 | offset = 80 83 | 84 | # Get number of transactions 85 | tx_count, size = var_int(data, offset) 86 | offset += size 87 | 88 | # List to store output values 89 | outputs = [] 90 | 91 | # Parse each transaction 92 | for _ in range(tx_count): 93 | offset = parse_transaction(data, offset, outputs) 94 | 95 | return outputs 96 | 97 | ############################################################################### 98 | 99 | # Quick Start 100 | 101 | ############################################################################### 102 | 103 | # 1. Make sure you have python3 and bitcoin-cli installed 104 | # 2. Make sure "server = 1" is in bitcoin.conf 105 | # 3. Run this file as "python3 UTXOracle.py" 106 | 107 | # If this isn't working for you, you'll likely need to explore the 108 | # bitcon-cli configuration options below: 109 | 110 | # configuration options for lighting-cli 111 | datadir = "" 112 | rpcuser = "" 113 | rpcpassword = "" 114 | rpcookiefile = "" 115 | rpcconnect = "" 116 | rpcport = "" 117 | conf = "" 118 | 119 | # add the configuration options to the lightning-cli call 120 | lightning_cli_options = [] 121 | if datadir != "": 122 | lightning_cli_options.append('-datadir='+ datadir) 123 | if rpcuser != "": 124 | lightning_cli_options.append("-rpcuser="+ rpcuser) 125 | if rpcpassword != "": 126 | lightning_cli_options.append("-rpcpassword="+ rpcpassword) 127 | if rpcookiefile != "": 128 | lightning_cli_options.append("-rpcookiefile="+ rpcookiefile) 129 | if rpcconnect != "": 130 | lightning_cli_options.append("-rpcconnect="+ rpcconnect) 131 | if rpcport != "": 132 | lightning_cli_options.append("-rpcport="+ rpcport) 133 | if conf != "": 134 | lightning_cli_options.append("-conf="+ conf) 135 | 136 | ############################################################################### 137 | 138 | # Part 1) Defining a shortcut function to call your node 139 | 140 | ############################################################################### 141 | 142 | # Here we define define a shortcut (a function) for calling the node as we do 143 | # this many times through out the program. The function will return the 144 | # answer it gets from your node with the "command" that you asked it for. 145 | # If we don't get an answer, the problem is likely that you don't have 146 | # sever=1 in your bitcoin conf file. 147 | 148 | # Stable Channel modfication = modified this function to work with "lightning-cli" 149 | import subprocess #a built in python library for sending command line commands 150 | def Ask_Node(command): 151 | 152 | for o in lightning_cli_options: 153 | command.insert(0,o) 154 | command.insert(0,"lightning-cli --testnet") 155 | 156 | # get the answer from the node and return it to the program 157 | answer = None 158 | try: #python try is used when we need to deal with errors after 159 | answer = subprocess.check_output(command) 160 | except Exception as e: 161 | # something went wrong while getting the answer 162 | print("Error connecting to your Lightning node. Trouble shooting steps:\n") 163 | print("\nThe command was:"+str(command)) 164 | print("\nThe error from lightning-cli was:\n") 165 | print(e) 166 | exit() 167 | 168 | # answer received, return this answer to the program 169 | return answer 170 | 171 | ############################################################################### 172 | 173 | # Part 2) Get the latest block from the node 174 | 175 | ############################################################################### 176 | 177 | # The first request to the node is to ask it how many blocks it has. This 178 | # let's us know the maximum possible day for which we can request a 179 | # btc price estimate. The time information of blocks is listed in the block 180 | # header, so we ask for the header only when we just need to know the time. 181 | 182 | # Get current block height from local node and exit if connection not made 183 | 184 | # Stable Channels modificaiton = change 'getblockcount' to 'getchaininfo' 185 | getchaininfo_result = Ask_Node(['getchaininfo']) 186 | block_count_b = int(getchaininfo_result["blockcount"]) 187 | 188 | # Stable Channels modificaiton = change 'getblockheader' to TODO 189 | # Get raw block; extract header 190 | block_header_b = Ask_Node(['getblockheader', block_count_b[:64],'true']) 191 | 192 | import json #a built in tool for deciphering lists of embedded lists 193 | block_header = json.loads(block_header_b) 194 | 195 | #get the date and time of the current block height 196 | from datetime import datetime, timezone #built in tools for dates/times 197 | latest_time_in_seconds = block_header['time'] 198 | time_datetime = datetime.fromtimestamp(latest_time_in_seconds,tz=timezone.utc) 199 | 200 | #get the date/time of utc midnight on the latest day 201 | latest_year = int(time_datetime.strftime("%Y")) 202 | latest_month = int(time_datetime.strftime("%m")) 203 | latest_day = int(time_datetime.strftime("%d")) 204 | latest_utc_midnight = datetime(latest_year,latest_month,latest_day,0,0,0,tzinfo=timezone.utc) 205 | 206 | #assign the day before as the latest possible price date 207 | seconds_in_a_day = 60*60*24 208 | yesterday_seconds = latest_time_in_seconds - seconds_in_a_day 209 | latest_price_day = datetime.fromtimestamp(yesterday_seconds,tz=timezone.utc) 210 | latest_price_date = latest_price_day.strftime("%Y-%m-%d") 211 | 212 | # tell the user that a connection has been made and state the lastest price date 213 | print("Connected to local noode at block #:\t"+str(block_count)) 214 | print("Latest available price date is: \t"+latest_price_date) 215 | print("Earliest available price date is:\t2020-07-26 (full node)") 216 | 217 | 218 | ############################################################################### 219 | 220 | # Part 3) Ask for the desired date to estimate the price 221 | 222 | ############################################################################### 223 | 224 | 225 | #use python input to get date from the user 226 | date_entered = input("\nEnter date in YYYY-MM-DD (or 'q' to quit):") 227 | 228 | # quit if desired 229 | if date_entered == 'q': 230 | exit() 231 | 232 | #check to see if this is a good date 233 | try: 234 | year = int(date_entered.split('-')[0]) 235 | month = int(date_entered.split('-')[1]) 236 | day = int(date_entered.split('-')[2]) 237 | 238 | #make sure this date is less than the max date 239 | datetime_entered = datetime(year,month,day,0,0,0,tzinfo=timezone.utc) 240 | if datetime_entered.timestamp() >= latest_utc_midnight.timestamp(): 241 | print("\nThe date entered is not before the current date, please try again") 242 | exit() 243 | 244 | #make sure this date is after the min date 245 | july_26_2020 = datetime(2020,7,26,0,0,0,tzinfo=timezone.utc) 246 | if datetime_entered.timestamp() < july_26_2020.timestamp(): 247 | print("\nThe date entered is before 2020-07-26, please try again") 248 | exit() 249 | 250 | except: 251 | print("\nError interpreting date. Likely not entered in format YYYY-MM-DD") 252 | print("Please try again\n") 253 | exit() 254 | 255 | #get the seconds and printable date string of date entered 256 | price_day_seconds = int(datetime_entered.timestamp()) 257 | price_day_date_utc = datetime_entered.strftime("%B %d, %Y") 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | ############################################################################## 276 | 277 | # Part 4) Hunt through blocks to find the first block on the target day 278 | 279 | ############################################################################## 280 | 281 | # This section would be unnecessary if bitcoin Core blocks were organized by time 282 | # instead of by block height. There's no way to ask bitcoin Core for a block at a 283 | # specific time. Instead one must ask for a block, look at it's time, then estimate 284 | # the number of blocks to jump for the next guess. Rinse and repeat. 285 | 286 | 287 | #first estimate of the block height of the price day 288 | seconds_since_price_day = latest_time_in_seconds - price_day_seconds 289 | blocks_ago_estimate = round(144*float(seconds_since_price_day)/float(seconds_in_a_day)) 290 | price_day_block_estimate = block_count - blocks_ago_estimate 291 | 292 | # Stable Channels modification = change 'getblockhash' to 'getrawblockbyheight' 293 | getrawblockbyheight_result = Ask_Node(['getrawblockbyheight',str(price_day_block_estimate)]) 294 | block_hash_b = getrawblockbyheight_result["blockhash"] 295 | 296 | # Stable Channels modification = change 'getblockheader' to TODO 297 | block_header_b = Ask_Node(['getblockheader',block_hash_b[:64],'true']) 298 | block_header = json.loads(block_header_b) 299 | time_in_seconds = block_header['time'] 300 | 301 | #get new block estimate from the seconds difference using 144 blocks per day 302 | seconds_difference = time_in_seconds - price_day_seconds 303 | block_jump_estimate = round(144*float(seconds_difference)/float(seconds_in_a_day)) 304 | 305 | #iterate above process until it oscillates around the correct block 306 | last_estimate = 0 307 | last_last_estimate = 0 308 | while block_jump_estimate >6 and block_jump_estimate != last_last_estimate: 309 | 310 | #when we osciallate around the correct block, last_last_estimate = block_jump_estimate 311 | last_last_estimate = last_estimate 312 | last_estimate = block_jump_estimate 313 | 314 | #get block header or new estimate 315 | price_day_block_estimate = price_day_block_estimate-block_jump_estimate 316 | # Stable Channels modification = change 'getblockhash' to TODO 317 | block_hash_b = Ask_Node(['getblockhash',str(price_day_block_estimate)]) 318 | # Stable Channels modification = change 'getblockheader' to TODO 319 | block_header_b = Ask_Node(['getblockheader',block_hash_b[:64],'true']) 320 | block_header = json.loads(block_header_b) 321 | 322 | #check time of new block and get new block jump estimate 323 | time_in_seconds = block_header['time'] 324 | seconds_difference = time_in_seconds - price_day_seconds 325 | block_jump_estimate = round(144*float(seconds_difference)/float(seconds_in_a_day)) 326 | 327 | #the oscillation may be over multiple blocks so we add/subtract single blocks 328 | #to ensure we have exactly the first block of the target day 329 | if time_in_seconds > price_day_seconds: 330 | 331 | # if the estimate was after price day look at earlier blocks 332 | while time_in_seconds > price_day_seconds: 333 | 334 | #decrement the block by one, read new block header, check time 335 | price_day_block_estimate = price_day_block_estimate-1 336 | # Stable Channels modification = change 'getblockhash' to TODO 337 | block_hash_b = Ask_Node(['getblockhash',str(price_day_block_estimate)]) 338 | # Stable Channels modification = change 'getblockheader' to TODO 339 | block_header_b = Ask_Node(['getblockheader',block_hash_b[:64],'true']) 340 | block_header = json.loads(block_header_b) 341 | time_in_seconds = block_header['time'] 342 | 343 | #the guess is now perfectly the first block before midnight 344 | price_day_block_estimate = price_day_block_estimate + 1 345 | 346 | # if the estimate was before price day look for later blocks 347 | elif time_in_seconds < price_day_seconds: 348 | 349 | while time_in_seconds < price_day_seconds: 350 | 351 | #increment the block by one, read new block header, check time 352 | price_day_block_estimate = price_day_block_estimate+1 353 | # Stable Channels modification = change 'getblockhash' to TODO 354 | block_hash_b = Ask_Node(['getblockhash',str(price_day_block_estimate)]) 355 | # Stable Channels modification = change 'getblockheader' to TODO 356 | block_header_b = Ask_Node(['getblockheader',block_hash_b[:64],'true']) 357 | block_header = json.loads(block_header_b) 358 | time_in_seconds = block_header['time'] 359 | 360 | #assign the estimate as the price day block since it is correct now 361 | price_day_block = price_day_block_estimate 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | ############################################################################## 375 | 376 | # Part 5) Build the container to hold the output amounts bell curve 377 | 378 | ############################################################################## 379 | 380 | # In pure math a bell curve can be perfectly smooth. But to make a bell curve 381 | # from a sample of data, one must specifiy a series of buckets, or bins, and then 382 | # count how many samples are in each bin. If the bin size is too large, say just one 383 | # large bin, a bell curve can't appear because it will have only one bar. The bell 384 | # curve also doesn't appear if the bin size is too small because then there will 385 | # only be one sample in each bin and we'd fail to have a distribution of bin heights. 386 | 387 | # Although several bin sizes would work, I have found over many years, that 200 bins 388 | # for every 10x of bitcoin amounts works very well. We use 'every 10x' because just 389 | # like a long term bitcoin price chart, viewing output amounts in log scale provides 390 | # a more comprehensive and detailed overview of the amounts being analyzed. 391 | 392 | 393 | # Define the maximum and minimum values (in log10) of btc amounts to use 394 | first_bin_value = -6 395 | last_bin_value = 6 #python -1 means last in list 396 | range_bin_values = last_bin_value - first_bin_value 397 | 398 | # create a list of output_bell_curve_bins and add zero sats as the first bin 399 | output_bell_curve_bins = [0.0] #a decimal tells python the list will contain decimals 400 | 401 | # calculate btc amounts of 200 samples in every 10x from 100 sats (1e-6 btc) to 100k (1e5) btc 402 | for exponent in range(-6,6): #python range uses 'less than' for the big number 403 | 404 | #add 200 bin_width increments in this 10x to the list 405 | for b in range(0,200): 406 | 407 | bin_value = 10 ** (exponent + b/200) 408 | output_bell_curve_bins.append(bin_value) 409 | 410 | # Create a list the same size as the bell curve to keep the count of the bins 411 | number_of_bins = len(output_bell_curve_bins) 412 | output_bell_curve_bin_counts = [] 413 | for n in range(0,number_of_bins): 414 | output_bell_curve_bin_counts.append(float(0.0)) 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | ############################################################################## 433 | 434 | # Part 6) Get all output amounts from all block on target day 435 | 436 | ############################################################################## 437 | 438 | # This section of the program will take the most time as it requests all 439 | # all blocks from Core on the price day. It readers every transaction (tx) 440 | # from those blocks and places each tx output value into the bell curve 441 | 442 | from math import log10 #built in math functions needed logarithms 443 | 444 | #print header line of update table 445 | print("\nReading all blocks on "+price_day_date_utc+"...") 446 | print("\nThis will take a few minutes (~144 blocks)...") 447 | print("\nHeight\tTime(utc)\t\tTime(32bit)\t\t Completion %") 448 | 449 | #get the full data of the first target day block from the node 450 | block_height=price_day_block 451 | block_hash_b = Ask_Node(['getblockhash',str(block_height)]) 452 | block_b = Ask_Node(['getblock',block_hash_b[:64],'2']) 453 | block = json.loads(block_b) 454 | 455 | #get the time of the first block 456 | time_in_seconds = int(block['time']) 457 | time_datetime = datetime.fromtimestamp(time_in_seconds,tz=timezone.utc) 458 | time_utc = time_datetime.strftime("%H:%M:%S") 459 | hour_of_day = int(time_datetime.strftime("%H")) 460 | minute_of_hour = float(time_datetime.strftime("%M")) 461 | day_of_month = int(time_datetime.strftime("%d")) 462 | target_day_of_month = day_of_month 463 | time_32bit = f"{time_in_seconds & 0b11111111111111111111111111111111:32b}" 464 | 465 | 466 | #read in blocks until we get a block on the day after the target day 467 | while target_day_of_month == day_of_month: 468 | 469 | #get progress estimate 470 | progress_estimate = 100.0*(hour_of_day+minute_of_hour/60)/24.0 471 | 472 | #print progress update 473 | print(str(block_height)+"\t"+time_utc+"\t"+time_32bit+"\t"+f"{progress_estimate:.2f}"+"%") 474 | 475 | hex_block = Ask_Node(['getrawblockbyheight',str(block_height)]) 476 | 477 | outputs = get_raw_block_utxo_values(hex_block) 478 | 479 | #go through all outputs in the tx 480 | for output in outputs: 481 | 482 | #the bitcoin output amount is called 'value' in Core, add this to the list 483 | amount = float(output['value']) 484 | 485 | #tiny and huge amounts aren't used by the USD price finder 486 | if 1e-6 < amount < 1e6: 487 | 488 | #take the log 489 | amount_log = log10(amount) 490 | 491 | #find the right output amount bin to increment 492 | percent_in_range = (amount_log-first_bin_value)/range_bin_values 493 | bin_number_est = int(percent_in_range * number_of_bins) 494 | 495 | #search for the exact right bin (won't be less than) 496 | while output_bell_curve_bins[bin_number_est] <= amount: 497 | bin_number_est += 1 498 | bin_number = bin_number_est - 1 499 | 500 | #increment the output bin 501 | output_bell_curve_bin_counts[bin_number] += 1.0 #+= means increment 502 | 503 | 504 | #get the full data of the next block 505 | block_height = block_height + 1 506 | block_hash_b = Ask_Node(['getblockhash',str(block_height)]) 507 | block_b = Ask_Node(['getblock',block_hash_b[:64],'2']) 508 | block = json.loads(block_b) 509 | 510 | #get the time of the next block 511 | time_in_seconds = int(block['time']) 512 | time_datetime = datetime.fromtimestamp(time_in_seconds,tz=timezone.utc) 513 | time_utc = time_datetime.strftime("%H:%M:%S") 514 | day_of_month = int(time_datetime.strftime("%d")) 515 | minute_of_hour = float(time_datetime.strftime("%M")) 516 | hour_of_day = int(time_datetime.strftime("%H")) 517 | time_32bit = f"{time_in_seconds & 0b11111111111111111111111111111111:32b}" 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | ############################################################################## 535 | 536 | # Part 7) Remove non-usd related output amounts from the bell curve 537 | 538 | ############################################################################## 539 | 540 | 541 | 542 | # This sectoins aims to remove non-usd denominated samples from the bell curve 543 | # of outputs. The two primary steps are to remove very large/small outputs 544 | # and then to remove round btc amounts. We don't set the round btc amounts 545 | # to zero because if the USD price of bitcoin is also round, then round 546 | # btc amounts will co-align with round usd amounts. There are many ways to deal 547 | # with this. One way we've found to work is to smooth over the round btc amounts 548 | # using the neighboring amounts in the bell curve. The last step is to normalize 549 | # the bell curve. Normalizing is done by dividing the entire curve by the sum 550 | # of the curve, and then removing extreme values. 551 | 552 | 553 | #remove ouputs below 1k sats 554 | for n in range(0,201): 555 | output_bell_curve_bin_counts[n]=0 556 | 557 | #remove outputs above ten btc 558 | for n in range(1601,number_of_bins): 559 | output_bell_curve_bin_counts[n]=0 560 | 561 | #create a list of round btc bin numbers 562 | round_btc_bins = [ 563 | 201, # 1k sats 564 | 401, # 10k 565 | 461, # 20k 566 | 496, # 30k 567 | 540, # 50k 568 | 601, # 100k 569 | 661, # 200k 570 | 696, # 300k 571 | 740, # 500k 572 | 801, # 0.01 btc 573 | 861, # 0.02 574 | 896, # 0.03 575 | 940, # 0.04 576 | 1001, # 0.1 577 | 1061, # 0.2 578 | 1096, # 0.3 579 | 1140, # 0.5 580 | 1201 # 1 btc 581 | ] 582 | 583 | #smooth over the round btc amounts 584 | for r in round_btc_bins: 585 | amount_above = output_bell_curve_bin_counts[r+1] 586 | amount_below = output_bell_curve_bin_counts[r-1] 587 | output_bell_curve_bin_counts[r] = .5*(amount_above+amount_below) 588 | 589 | 590 | #get the sum of the curve 591 | curve_sum = 0.0 592 | for n in range(201,1601): 593 | curve_sum += output_bell_curve_bin_counts[n] 594 | 595 | #normalize the curve by dividing by it's sum and removing extreme values 596 | for n in range(201,1601): 597 | output_bell_curve_bin_counts[n] /= curve_sum 598 | 599 | #remove extremes (the iterative process mentioned below found 0.008 to work) 600 | if output_bell_curve_bin_counts[n] > 0.008: 601 | output_bell_curve_bin_counts[n] = 0.008 602 | 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | ############################################################################## 617 | 618 | # Part 8) Construct the USD price finder stencil 619 | 620 | ############################################################################## 621 | 622 | # We now have a bell curve of outputs which should contain round USD outputs 623 | # as it's prominent features. To expose these prominent features even more, 624 | # and estimate a usd price, we slide a stencil over the bell curve and look 625 | # for where the slide location is maximized. There are several stencil designs 626 | # and maximization strategies which could accomplish this. The one used here 627 | # is a stencil whose locations and heights have been found using the averages of 628 | # running this algorithm iteratively over the years 2020-2023. The stencil is 629 | # centered around 0.01 btc = $10,00 as this was the easiest mark to identify. 630 | # The result of this process has produced the following stencil design: 631 | 632 | 633 | # create an empty stencil the same size as the bell curve 634 | round_usd_stencil = [] 635 | for n in range(0,number_of_bins): 636 | round_usd_stencil.append(0.0) 637 | 638 | # fill the round usd stencil with the values found by the process mentioned above 639 | round_usd_stencil[401] = 0.0005957955691168063 # $1 640 | round_usd_stencil[402] = 0.0004454790662303128 # (next one for tx/atm fees) 641 | round_usd_stencil[429] = 0.0001763099393598914 # $1.50 642 | round_usd_stencil[430] = 0.0001851801497144573 643 | round_usd_stencil[461] = 0.0006205616481885794 # $2 644 | round_usd_stencil[462] = 0.0005985696860584984 645 | round_usd_stencil[496] = 0.0006919505728046619 # $3 646 | round_usd_stencil[497] = 0.0008912933078342840 647 | round_usd_stencil[540] = 0.0009372916238804205 # $5 648 | round_usd_stencil[541] = 0.0017125522985034724 # (larger needed range for fees) 649 | round_usd_stencil[600] = 0.0021702347223143030 650 | round_usd_stencil[601] = 0.0037018622326411380 # $10 651 | round_usd_stencil[602] = 0.0027322168706743802 652 | round_usd_stencil[603] = 0.0016268322583097678 # (larger needed range for fees) 653 | round_usd_stencil[604] = 0.0012601953416497664 654 | round_usd_stencil[661] = 0.0041425242880295460 # $20 655 | round_usd_stencil[662] = 0.0039247767475640830 656 | round_usd_stencil[696] = 0.0032399441632017228 # $30 657 | round_usd_stencil[697] = 0.0037112959007355585 658 | round_usd_stencil[740] = 0.0049921908828370000 # $50 659 | round_usd_stencil[741] = 0.0070636869018197105 660 | round_usd_stencil[801] = 0.0080000000000000000 # $100 661 | round_usd_stencil[802] = 0.0065431388282424440 # (larger needed range for fees) 662 | round_usd_stencil[803] = 0.0044279509203361735 663 | round_usd_stencil[861] = 0.0046132440551747015 # $200 664 | round_usd_stencil[862] = 0.0043647851395531140 665 | round_usd_stencil[896] = 0.0031980892880846567 # $300 666 | round_usd_stencil[897] = 0.0034237641632481910 667 | round_usd_stencil[939] = 0.0025995335505435034 # $500 668 | round_usd_stencil[940] = 0.0032631930982226645 # (larger needed range for fees) 669 | round_usd_stencil[941] = 0.0042753262790881080 670 | round_usd_stencil[1001] =0.0037699501474772350 # $1,000 671 | round_usd_stencil[1002] =0.0030872891064215764 # (larger needed range for fees) 672 | round_usd_stencil[1003] =0.0023237040836798163 673 | round_usd_stencil[1061] =0.0023671764210889895 # $2,000 674 | round_usd_stencil[1062] =0.0020106877104798474 675 | round_usd_stencil[1140] =0.0009099214128654502 # $3,000 676 | round_usd_stencil[1141] =0.0012008546799361498 677 | round_usd_stencil[1201] =0.0007862586076341524 # $10,000 678 | round_usd_stencil[1202] =0.0006900048077192579 679 | 680 | 681 | 682 | 683 | 684 | 685 | 686 | 687 | 688 | 689 | 690 | 691 | 692 | ############################################################################## 693 | 694 | # Part 9) Slide the stencil over the output bell curve to find the best fit 695 | 696 | ############################################################################## 697 | 698 | # This is the final step. We slide the stencil over the bell curve and see 699 | # where it fits the best. The best fit location and it's neighbor are used 700 | # in a weighted average to estimate the best fit USD price 701 | 702 | 703 | # set up scores for sliding the stencil 704 | best_slide = 0 705 | best_slide_score = 0.0 706 | total_score = 0.0 707 | number_of_scores = 0 708 | 709 | #upper and lower limits for sliding the stencil 710 | min_slide = -200 711 | max_slide = 200 712 | 713 | #slide the stencil and calculate slide score 714 | for slide in range(min_slide,max_slide): 715 | 716 | #shift the bell curve by the slide 717 | shifted_curve = output_bell_curve_bin_counts[201+slide:1401+slide] 718 | 719 | #score the shift by multiplying the curve by the stencil 720 | slide_score = 0.0 721 | for n in range(0,len(shifted_curve)): 722 | slide_score += shifted_curve[n]*round_usd_stencil[n+201] 723 | 724 | # increment total and number of scores 725 | total_score += slide_score 726 | number_of_scores += 1 727 | 728 | # see if this score is the best so far 729 | if slide_score > best_slide_score: 730 | best_slide_score = slide_score 731 | best_slide = slide 732 | 733 | # estimate the usd price of the best slide 734 | usd100_in_btc_best = output_bell_curve_bins[801+best_slide] 735 | btc_in_usd_best = 100/(usd100_in_btc_best) 736 | 737 | #find best slide neighbor up 738 | neighbor_up = output_bell_curve_bin_counts[201+best_slide+1:1401+best_slide+1] 739 | neighbor_up_score = 0.0 740 | for n in range(0,len(neighbor_up)): 741 | neighbor_up_score += neighbor_up[n]*round_usd_stencil[n+201] 742 | 743 | #find best slide neighbor down 744 | neighbor_down = output_bell_curve_bin_counts[201+best_slide-1:1401+best_slide-1] 745 | neighbor_down_score = 0.0 746 | for n in range(0,len(neighbor_down)): 747 | neighbor_down_score += neighbor_down[n]*round_usd_stencil[n+201] 748 | 749 | #get best neighbor 750 | best_neighbor = +1 751 | neighbor_score = neighbor_up_score 752 | if neighbor_down_score > neighbor_up_score: 753 | best_neighbor = -1 754 | neighbor_score = neighbor_down_score 755 | 756 | #get best neighbor usd price 757 | usd100_in_btc_2nd = output_bell_curve_bins[801+best_slide+best_neighbor] 758 | btc_in_usd_2nd = 100/(usd100_in_btc_2nd) 759 | 760 | #weight average the two usd price estimates 761 | avg_score = total_score/number_of_scores 762 | a1 = best_slide_score - avg_score 763 | a2 = abs(neighbor_score - avg_score) #theoretically possible to be negative 764 | w1 = a1/(a1+a2) 765 | w2 = a2/(a1+a2) 766 | price_estimate = int(w1*btc_in_usd_best + w2*btc_in_usd_2nd) 767 | 768 | #report the price estimate 769 | print("\nThe "+price_day_date_utc+" btc price estimate is: $" + f'{price_estimate:,}') 770 | 771 | 772 | 773 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyln-client 2 | cachetools 3 | requests 4 | statistics 5 | apscheduler -------------------------------------------------------------------------------- /src/audit.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | use serde_json::Value; 3 | use std::sync::OnceLock; 4 | 5 | 6 | static AUDIT_LOG_PATH: OnceLock = OnceLock::new(); 7 | 8 | pub fn set_audit_log_path(path: &str) { 9 | let _ = AUDIT_LOG_PATH.set(path.to_owned()); 10 | } 11 | 12 | pub fn get_audit_log_path() -> Option<&'static str> { 13 | AUDIT_LOG_PATH.get().map(|s| s.as_str()) 14 | } 15 | 16 | pub fn audit_event(event: &str, data: Value) { 17 | if let Some(path_str) = get_audit_log_path() { 18 | let path = std::path::Path::new(path_str); 19 | 20 | if let Some(parent) = path.parent() { 21 | let _ = std::fs::create_dir_all(parent); 22 | } 23 | 24 | // compose log line 25 | let log_line = serde_json::json!({ 26 | "ts": chrono::Utc::now().to_rfc3339(), 27 | "event": event, 28 | "data": data 29 | }); 30 | 31 | // append to file 32 | if let Ok(mut file) = std::fs::OpenOptions::new() 33 | .create(true) 34 | .append(true) 35 | .open(path) 36 | { 37 | let _ = writeln!(file, "{}", log_line); 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /src/ldk_node_adapter.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | use ldk_node::{ 3 | bitcoin::secp256k1::PublicKey, config::ChannelConfig, lightning::ln::msgs::SocketAddress, payment::{Bolt11Payment, OnchainPayment, SpontaneousPayment}, BalanceDetails, ChannelDetails, Event, Node, UserChannelId 4 | }; 5 | use crate::lightning::{LightningError, LightningNode}; 6 | 7 | pub struct LdkNodeAdapter(pub Arc); 8 | 9 | impl LightningNode for LdkNodeAdapter { 10 | // These are all ~read functions 11 | fn node_id(&self) -> ldk_node::bitcoin::secp256k1::PublicKey { 12 | self.0.node_id() 13 | } 14 | 15 | fn listening_addresses(&self) -> Option> { 16 | self.0.listening_addresses() 17 | } 18 | 19 | fn list_balances(&self) -> BalanceDetails { 20 | self.0.list_balances() 21 | } 22 | 23 | fn list_channels(&self) -> Vec { 24 | self.0.list_channels() 25 | } 26 | 27 | fn next_event(&self) -> Option { 28 | self.0.next_event() 29 | } 30 | 31 | fn event_handled(&self) { 32 | let _ = self.0.event_handled(); 33 | } 34 | 35 | // These are all ~write functions 36 | fn spontaneous_payment(&self) -> SpontaneousPayment { 37 | self.0.spontaneous_payment() 38 | } 39 | 40 | fn bolt11_payment(&self) -> Bolt11Payment { 41 | self.0.bolt11_payment() 42 | } 43 | 44 | fn onchain_payment(&self) -> OnchainPayment { 45 | self.0.onchain_payment() 46 | } 47 | 48 | fn force_close_channel( 49 | &self, 50 | user_channel_id: &UserChannelId, 51 | counterparty_node_id: PublicKey, 52 | reason: Option, 53 | ) -> Result<(), LightningError> { 54 | self.0 55 | .force_close_channel(user_channel_id, counterparty_node_id, reason) 56 | .map_err(|e| LightningError::LdkError(e.to_string())) 57 | } 58 | 59 | fn open_announced_channel( 60 | &self, 61 | node_id: PublicKey, 62 | address: SocketAddress, 63 | channel_amount_sats: u64, 64 | push_amount_msat: Option, 65 | config: Option, 66 | ) -> Result { 67 | self.0 68 | .open_announced_channel(node_id, address, channel_amount_sats, push_amount_msat, config) 69 | .map_err(|e| LightningError::LdkError(e.to_string())) 70 | } 71 | 72 | fn close_channel( 73 | &self, 74 | user_channel_id: &UserChannelId, 75 | counterparty_node_id: PublicKey, 76 | ) -> Result<(), LightningError> { 77 | self.0 78 | .close_channel(user_channel_id, counterparty_node_id) 79 | .map_err(|e| LightningError::LdkError(e.to_string())) 80 | } 81 | } -------------------------------------------------------------------------------- /src/lightning.rs: -------------------------------------------------------------------------------- 1 | use ldk_node::{bitcoin::secp256k1::PublicKey, config::ChannelConfig, lightning::ln::msgs::SocketAddress, payment::{Bolt11Payment, OnchainPayment, SpontaneousPayment}, BalanceDetails, ChannelDetails, Event, UserChannelId}; 2 | 3 | use std::fmt; 4 | 5 | // I had to wrap this for some reason ... error internal 6 | pub enum LightningError { 7 | LdkError(String), 8 | } 9 | 10 | pub trait LightningNode: Send + Sync { 11 | // These are all ~read functions 12 | fn list_balances(&self) -> BalanceDetails; 13 | fn list_channels(&self) -> Vec; 14 | 15 | fn node_id(&self) -> PublicKey; 16 | fn listening_addresses(&self) -> Option>; 17 | 18 | fn next_event(&self) -> Option; 19 | fn event_handled(&self); 20 | 21 | // These are all ~write functions 22 | fn open_announced_channel( 23 | &self, 24 | node_id: PublicKey, 25 | address: SocketAddress, 26 | channel_amount_sats: u64, 27 | push_amount_msat: Option, 28 | config: Option, 29 | ) -> Result; 30 | 31 | fn bolt11_payment(&self) -> Bolt11Payment; 32 | 33 | fn spontaneous_payment(&self) -> SpontaneousPayment; 34 | 35 | fn onchain_payment(&self) -> OnchainPayment; 36 | 37 | fn close_channel( 38 | &self, 39 | user_channel_id: &UserChannelId, 40 | counterparty_node_id: PublicKey, 41 | ) -> Result<(), LightningError>; 42 | 43 | fn force_close_channel( 44 | &self, 45 | user_channel_id: &ldk_node::UserChannelId, 46 | counterparty_node_id: ldk_node::bitcoin::secp256k1::PublicKey, 47 | reason: Option, 48 | ) -> Result<(), LightningError>; 49 | 50 | 51 | } 52 | 53 | impl fmt::Display for LightningError { 54 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 55 | match self { 56 | LightningError::LdkError(s) => write!(f, "LDK error: {}", s), 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | /// Stable Channels in LDK 2 | /// Contents 3 | /// Main data structure and helper types are in `types.rs`. 4 | /// The price feed config and logic is in price_feeds.rs. 5 | /// User-facing (stability) code in user.rs 6 | /// Server code in server.rs 7 | /// This present file includes LDK set-up, program initialization, 8 | /// a command-line interface, and the core stability logic. 9 | /// We have three different services: exchange, user, and lsp 10 | 11 | use std::env; 12 | 13 | pub mod price_feeds; 14 | pub mod types; 15 | pub mod audit; 16 | pub mod stable; 17 | pub mod user; 18 | pub mod server; 19 | pub mod lightning; 20 | pub mod ldk_node_adapter; 21 | 22 | fn main() { 23 | let mode = env::args().nth(1).unwrap_or_else(|| "user".to_string()); 24 | 25 | match mode.as_str() { 26 | "user" => user::run(), 27 | "lsp" | "exchange" => server::run_with_mode(&mode), 28 | _ => { 29 | eprintln!("Unknown mode: '{}'. Use: `user`, `lsp`, or `exchange`", mode); 30 | std::process::exit(1); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/price_feeds.rs: -------------------------------------------------------------------------------- 1 | use ureq::Agent; 2 | use serde_json::Value; 3 | use std::error::Error; 4 | use std::sync::{Arc, Mutex}; 5 | use std::time::{Duration, Instant}; 6 | use retry::{retry, delay::Fixed}; 7 | use crate::audit::audit_event; 8 | use serde_json::json; 9 | 10 | 11 | lazy_static::lazy_static! { 12 | static ref PRICE_CACHE: Arc> = Arc::new(Mutex::new(PriceCache { 13 | price: 0.0, 14 | last_update: Instant::now() - Duration::from_secs(10), 15 | updating: false, 16 | })); 17 | } 18 | 19 | // A very simple price cache structure 20 | pub struct PriceCache { 21 | price: f64, 22 | last_update: Instant, 23 | updating: bool, 24 | } 25 | 26 | pub struct PriceFeed { 27 | pub name: String, 28 | pub urlformat: String, 29 | pub jsonpath: Vec, 30 | } 31 | 32 | impl PriceFeed { 33 | pub fn new(name: &str, urlformat: &str, jsonpath: Vec<&str>) -> PriceFeed { 34 | PriceFeed { 35 | name: name.to_string(), 36 | urlformat: urlformat.to_string(), 37 | jsonpath: jsonpath.iter().map(|&s| s.to_string()).collect(), 38 | } 39 | } 40 | } 41 | 42 | // Get cached price or fetch a new one if needed 43 | pub fn get_cached_price() -> f64 { 44 | // First check if we need to update 45 | let should_update = { 46 | let cache = PRICE_CACHE.lock().unwrap(); 47 | cache.last_update.elapsed() > Duration::from_secs(5) && !cache.updating 48 | }; 49 | 50 | // If update is needed 51 | if should_update { 52 | // Get the lock again and mark as updating 53 | let mut cache = PRICE_CACHE.lock().unwrap(); 54 | cache.updating = true; 55 | drop(cache); 56 | 57 | // Try to fetch a new price 58 | let agent = Agent::new(); 59 | if let Ok(new_price) = get_latest_price(&agent) { 60 | // Update the cache with new price 61 | let mut cache = PRICE_CACHE.lock().unwrap(); 62 | cache.price = new_price; 63 | cache.last_update = Instant::now(); 64 | cache.updating = false; 65 | audit_event("PRICE_FETCH", json!({ "btc_price": new_price })); 66 | return new_price; 67 | } else { 68 | // Update failed, just clear the updating flag 69 | let mut cache = PRICE_CACHE.lock().unwrap(); 70 | cache.updating = false; 71 | return cache.price; // Return the existing price 72 | } 73 | } 74 | 75 | // No update needed, just return current price 76 | let cache = PRICE_CACHE.lock().unwrap(); 77 | cache.price 78 | } 79 | 80 | pub fn set_price_feeds() -> Vec { 81 | vec![ 82 | PriceFeed::new( 83 | "Bitstamp", 84 | "https://www.bitstamp.net/api/v2/ticker/btcusd/", 85 | vec!["last"], 86 | ), 87 | PriceFeed::new( 88 | "CoinGecko", 89 | "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd", 90 | vec!["bitcoin", "usd"], 91 | ), 92 | // PriceFeed::new( 93 | // "Coindesk", 94 | // "https://api.coindesk.com/v1/bpi/currentprice/USD.json", 95 | // vec!["bpi", "USD", "rate_float"], 96 | // ), 97 | PriceFeed::new( 98 | "Coinbase", 99 | "https://api.coinbase.com/v2/prices/spot?currency=USD", 100 | vec!["data", "amount"], 101 | ), 102 | PriceFeed::new( 103 | "Blockchain.com", 104 | "https://blockchain.info/ticker", 105 | vec!["USD", "last"], 106 | ), 107 | ] 108 | } 109 | 110 | pub fn fetch_prices( 111 | agent: &Agent, 112 | price_feeds: &[PriceFeed], 113 | ) -> Result, Box> { 114 | let mut prices = Vec::new(); 115 | 116 | for price_feed in price_feeds { 117 | let url: String = price_feed 118 | .urlformat 119 | .replace("{currency_lc}", "usd") 120 | .replace("{currency}", "USD"); 121 | 122 | let response = retry(Fixed::from_millis(300).take(3), || { 123 | match agent.get(&url).call() { 124 | Ok(resp) => { 125 | if resp.status() >= 200 && resp.status() < 300 { 126 | Ok(resp) 127 | } else { 128 | Err(format!("Received status code: {}", resp.status())) 129 | } 130 | } 131 | Err(e) => Err(e.to_string()), 132 | } 133 | }) 134 | .map_err(|e| -> Box { 135 | Box::new(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())) 136 | })?; 137 | 138 | let json: Value = response.into_json()?; 139 | let mut data = &json; 140 | 141 | for key in &price_feed.jsonpath { 142 | if let Some(inner_data) = data.get(key) { 143 | data = inner_data; 144 | } else { 145 | println!( 146 | "Key '{}' not found in the response from {}", 147 | key, price_feed.name 148 | ); 149 | continue; 150 | } 151 | } 152 | 153 | if let Some(price) = data.as_f64() { 154 | prices.push((price_feed.name.clone(), price)); 155 | } else if let Some(price_str) = data.as_str() { 156 | if let Ok(price) = price_str.parse::() { 157 | prices.push((price_feed.name.clone(), price)); 158 | } else { 159 | println!("Invalid price format for {}: {}", price_feed.name, price_str); 160 | } 161 | } else { 162 | println!( 163 | "Price data not found or invalid format for {}", 164 | price_feed.name 165 | ); 166 | } 167 | } 168 | 169 | if prices.len() < 5 { 170 | // println!("Fewer than 5 prices fetched."); 171 | } 172 | 173 | if prices.is_empty() { 174 | return Err("No valid prices fetched.".into()); 175 | } 176 | 177 | Ok(prices) 178 | } 179 | 180 | pub fn get_latest_price(agent: &Agent) -> Result> { 181 | let price_feeds = set_price_feeds(); 182 | let prices = fetch_prices(agent, &price_feeds)?; 183 | 184 | // Print all prices 185 | for (feed_name, price) in &prices { 186 | println!("{:<25} ${:>1.2}", feed_name, price); 187 | } 188 | 189 | // Calculate the median price 190 | let mut price_values: Vec = prices.iter().map(|(_, price)| *price).collect(); 191 | price_values.sort_by(|a, b| a.partial_cmp(b).unwrap()); 192 | let median_price = if price_values.len() % 2 == 0 { 193 | (price_values[price_values.len() / 2 - 1] + price_values[price_values.len() / 2]) / 2.0 194 | } else { 195 | price_values[price_values.len() / 2] 196 | }; 197 | 198 | println!("\nMedian BTC/USD price: ${:.2}\n", median_price); 199 | Ok(median_price) 200 | } -------------------------------------------------------------------------------- /src/stable.rs: -------------------------------------------------------------------------------- 1 | use crate::lightning::LightningNode; 2 | use crate::types::{Bitcoin, StableChannel, USD}; 3 | use ldk_node::{ 4 | lightning::ln::types::ChannelId, Node, 5 | }; 6 | use ureq::Agent; 7 | use crate::price_feeds::get_cached_price; 8 | use crate::audit::audit_event; 9 | use serde_json::json; 10 | use ldk_node::CustomTlvRecord; 11 | 12 | pub fn get_current_price(agent: &Agent) -> f64 { 13 | let cached_price = get_cached_price(); 14 | if cached_price > 0.0 { 15 | return cached_price; 16 | } 17 | match crate::price_feeds::get_latest_price(agent) { 18 | Ok(price) => price, 19 | Err(_) => 0.0 20 | } 21 | } 22 | 23 | pub fn channel_exists(node: &dyn LightningNode, channel_id: &ChannelId) -> bool { 24 | let channels = node.list_channels(); 25 | channels.iter().any(|c| c.channel_id == *channel_id) 26 | } 27 | 28 | pub fn update_balances<'a>(node: &dyn LightningNode, sc: &'a mut StableChannel) -> (bool, &'a mut StableChannel) { 29 | internal_update_balances(node.list_channels(), sc) 30 | } 31 | 32 | pub fn update_balances_node<'a>(node: &Node, sc: &'a mut StableChannel) -> (bool, &'a mut StableChannel) { 33 | internal_update_balances(node.list_channels(), sc) 34 | } 35 | 36 | fn internal_update_balances<'a>( 37 | channels: Vec, 38 | sc: &'a mut StableChannel, 39 | ) -> (bool, &'a mut StableChannel) { 40 | if sc.latest_price == 0.0 { 41 | sc.latest_price = get_cached_price(); 42 | if sc.latest_price == 0.0 { 43 | let agent = Agent::new(); 44 | sc.latest_price = get_current_price(&agent); 45 | } 46 | } 47 | 48 | let matching_channel = if sc.channel_id == ChannelId::from_bytes([0; 32]) { 49 | channels.first() 50 | } else { 51 | channels.iter().find(|c| c.channel_id == sc.channel_id) 52 | }; 53 | 54 | if let Some(channel) = matching_channel { 55 | if sc.channel_id == ChannelId::from_bytes([0; 32]) { 56 | sc.channel_id = channel.channel_id; 57 | println!("Set active channel ID to: {}", sc.channel_id); 58 | } 59 | 60 | let unspendable_punishment_sats = channel.unspendable_punishment_reserve.unwrap_or(0); 61 | let our_balance_sats = (channel.outbound_capacity_msat / 1000) + unspendable_punishment_sats; 62 | let their_balance_sats = channel.channel_value_sats - our_balance_sats; 63 | 64 | if sc.is_stable_receiver { 65 | sc.stable_receiver_btc = Bitcoin::from_sats(our_balance_sats); 66 | sc.stable_provider_btc = Bitcoin::from_sats(their_balance_sats); 67 | } else { 68 | sc.stable_provider_btc = Bitcoin::from_sats(our_balance_sats); 69 | sc.stable_receiver_btc = Bitcoin::from_sats(their_balance_sats); 70 | } 71 | 72 | sc.stable_receiver_usd = USD::from_bitcoin(sc.stable_receiver_btc, sc.latest_price); 73 | sc.stable_provider_usd = USD::from_bitcoin(sc.stable_provider_btc, sc.latest_price); 74 | 75 | audit_event("BALANCE_UPDATE", json!({ 76 | "channel_id": format!("{}", sc.channel_id), 77 | "stable_receiver_btc": sc.stable_receiver_btc.to_string(), 78 | "stable_provider_btc": sc.stable_provider_btc.to_string(), 79 | "stable_receiver_usd": sc.stable_receiver_usd.to_string(), 80 | "stable_provider_usd": sc.stable_provider_usd.to_string(), 81 | "btc_price": sc.latest_price 82 | })); 83 | 84 | return (true, sc); 85 | } 86 | 87 | println!("No matching channel found for ID: {}", sc.channel_id); 88 | (true, sc) 89 | } 90 | 91 | pub fn check_stability(node: &dyn LightningNode, sc: &mut StableChannel, price: f64) { 92 | let current_price = if price > 0.0 { 93 | price 94 | } else { 95 | let cached_price = get_cached_price(); 96 | if cached_price > 0.0 { 97 | cached_price 98 | } else { 99 | audit_event("STABILITY_SKIP", json!({ 100 | "reason": "no valid price available" 101 | })); 102 | return; 103 | } 104 | }; 105 | 106 | sc.latest_price = current_price; 107 | let (success, _) = update_balances(node, sc); 108 | 109 | if !success { 110 | audit_event("BALANCE_UPDATE_FAILED", json!({ 111 | "channel_id": format!("{}", sc.channel_id) 112 | })); 113 | return; 114 | } 115 | 116 | let dollars_from_par = sc.stable_receiver_usd - sc.expected_usd; 117 | let percent_from_par = ((dollars_from_par / sc.expected_usd) * 100.0).abs(); 118 | let is_receiver_below_expected = sc.stable_receiver_usd < sc.expected_usd; 119 | 120 | let action = if percent_from_par < 0.1 { 121 | "STABLE" 122 | } else if sc.risk_level > 100 { 123 | "HIGH_RISK_NO_ACTION" 124 | } else if (sc.is_stable_receiver && is_receiver_below_expected) 125 | || (!sc.is_stable_receiver && !is_receiver_below_expected) 126 | { 127 | "CHECK_ONLY" 128 | } else { 129 | "PAY" 130 | }; 131 | 132 | audit_event("STABILITY_CHECK", json!({ 133 | "expected_usd": sc.expected_usd.0, 134 | "current_receiver_usd": sc.stable_receiver_usd.0, 135 | "percent_from_par": percent_from_par, 136 | "btc_price": sc.latest_price, 137 | "action": action, 138 | "is_stable_receiver": sc.is_stable_receiver, 139 | "risk_level": sc.risk_level 140 | })); 141 | 142 | if action != "PAY" { 143 | return; 144 | } 145 | 146 | let amt = USD::to_msats(dollars_from_par, sc.latest_price); 147 | // match node.spontaneous_payment().send(amt, sc.counterparty, None) { 148 | // Ok(payment_id) => { 149 | // sc.payment_made = true; 150 | // audit_event("STABILITY_PAYMENT_SENT", json!({ 151 | // "amount_msats": amt, 152 | // "payment_id": payment_id.to_string(), 153 | // "counterparty": sc.counterparty.to_string() 154 | // })); 155 | // } 156 | // Err(e) => { 157 | // audit_event("STABILITY_PAYMENT_FAILED", json!({ 158 | // "amount_msats": amt, 159 | // "error": format!("{e}"), 160 | // "counterparty": sc.counterparty.to_string() 161 | // })); 162 | // } 163 | // } 164 | let custom_str = "hello_stable_channels"; 165 | let custom_tlv = CustomTlvRecord { 166 | type_num: 13377331, // choose an agreed-upon or arbitrary custom TLV type number 167 | value: custom_str.as_bytes().to_vec(), 168 | }; 169 | 170 | match node.spontaneous_payment().send_with_custom_tlvs(amt, sc.counterparty, None, vec![custom_tlv]) { 171 | Ok(payment_id) => { 172 | sc.payment_made = true; 173 | audit_event("STABILITY_PAYMENT_SENT", json!({ 174 | "amount_msats": amt, 175 | "payment_id": payment_id.to_string(), 176 | "counterparty": sc.counterparty.to_string(), 177 | "custom_tlv": custom_str 178 | })); 179 | } 180 | Err(e) => { 181 | audit_event("STABILITY_PAYMENT_FAILED", json!({ 182 | "amount_msats": amt, 183 | "error": format!("{e}"), 184 | "counterparty": sc.counterparty.to_string(), 185 | "custom_tlv": custom_str 186 | })); 187 | } 188 | } 189 | } 190 | 191 | pub fn check_stability_node(node: &Node, sc: &mut StableChannel, price: f64) { 192 | let current_price = if price > 0.0 { 193 | price 194 | } else { 195 | let cached_price = get_cached_price(); 196 | if cached_price > 0.0 { 197 | cached_price 198 | } else { 199 | audit_event("STABILITY_SKIP", json!({ 200 | "reason": "no valid price available" 201 | })); 202 | return; 203 | } 204 | }; 205 | 206 | sc.latest_price = current_price; 207 | let (success, _) = update_balances_node(node, sc); 208 | 209 | if !success { 210 | audit_event("BALANCE_UPDATE_FAILED", json!({ 211 | "channel_id": format!("{}", sc.channel_id) 212 | })); 213 | return; 214 | } 215 | 216 | let dollars_from_par = sc.stable_receiver_usd - sc.expected_usd; 217 | let percent_from_par = ((dollars_from_par / sc.expected_usd) * 100.0).abs(); 218 | let is_receiver_below_expected = sc.stable_receiver_usd < sc.expected_usd; 219 | 220 | let action = if percent_from_par < 0.1 { 221 | "STABLE" 222 | } else if sc.risk_level > 100 { 223 | "HIGH_RISK_NO_ACTION" 224 | } else if (sc.is_stable_receiver && is_receiver_below_expected) 225 | || (!sc.is_stable_receiver && !is_receiver_below_expected) 226 | { 227 | "CHECK_ONLY" 228 | } else { 229 | "PAY" 230 | }; 231 | 232 | audit_event("STABILITY_CHECK", json!({ 233 | "expected_usd": sc.expected_usd.0, 234 | "current_receiver_usd": sc.stable_receiver_usd.0, 235 | "percent_from_par": percent_from_par, 236 | "btc_price": sc.latest_price, 237 | "action": action, 238 | "is_stable_receiver": sc.is_stable_receiver, 239 | "risk_level": sc.risk_level 240 | })); 241 | 242 | if action != "PAY" { 243 | return; 244 | } 245 | 246 | let amt = USD::to_msats(dollars_from_par, sc.latest_price); 247 | match node.spontaneous_payment().send(amt, sc.counterparty, None) { 248 | Ok(payment_id) => { 249 | sc.payment_made = true; 250 | audit_event("STABILITY_PAYMENT_SENT", json!({ 251 | "amount_msats": amt, 252 | "payment_id": payment_id.to_string(), 253 | "counterparty": sc.counterparty.to_string() 254 | })); 255 | } 256 | Err(e) => { 257 | audit_event("STABILITY_PAYMENT_FAILED", json!({ 258 | "amount_msats": amt, 259 | "error": format!("{e}"), 260 | "counterparty": sc.counterparty.to_string() 261 | })); 262 | } 263 | } 264 | } 265 | 266 | 267 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | use ldk_node::bitcoin::secp256k1::PublicKey; 2 | use ldk_node::lightning::ln::types::ChannelId; 3 | use std::{ops::{Div, Sub}, time::{SystemTime, UNIX_EPOCH}}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | // Custom serialization for ChannelId 7 | mod channel_id_serde { 8 | use super::ChannelId; 9 | use serde::{Deserialize, Deserializer, Serializer, Serialize}; 10 | 11 | pub fn serialize(channel_id: &ChannelId, serializer: S) -> Result 12 | where 13 | S: Serializer, 14 | { 15 | // Serialize the inner bytes 16 | let bytes = channel_id.0; 17 | bytes.serialize(serializer) 18 | } 19 | 20 | pub fn deserialize<'de, D>(deserializer: D) -> Result 21 | where 22 | D: Deserializer<'de>, 23 | { 24 | let bytes = <[u8; 32]>::deserialize(deserializer)?; 25 | Ok(ChannelId(bytes)) 26 | } 27 | } 28 | 29 | // Custom serialization for PublicKey 30 | mod pubkey_serde { 31 | use ldk_node::bitcoin::secp256k1::PublicKey; 32 | use serde::{Deserialize, Deserializer, Serializer, Serialize}; 33 | use std::str::FromStr; 34 | 35 | pub fn serialize(pubkey: &PublicKey, serializer: S) -> Result 36 | where 37 | S: Serializer, 38 | { 39 | // Serialize as a string 40 | let pubkey_str = pubkey.to_string(); 41 | pubkey_str.serialize(serializer) 42 | } 43 | 44 | pub fn deserialize<'de, D>(deserializer: D) -> Result 45 | where 46 | D: Deserializer<'de>, 47 | { 48 | let pubkey_str = String::deserialize(deserializer)?; 49 | PublicKey::from_str(&pubkey_str).map_err(serde::de::Error::custom) 50 | } 51 | } 52 | 53 | #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] 54 | pub struct Bitcoin { 55 | pub sats: u64, // Stored in Satoshis for precision 56 | } 57 | 58 | impl Default for Bitcoin { 59 | fn default() -> Self { 60 | Self { sats: 0 } 61 | } 62 | } 63 | 64 | impl Bitcoin { 65 | const SATS_IN_BTC: u64 = 100_000_000; 66 | 67 | pub fn from_sats(sats: u64) -> Self { 68 | Self { sats } 69 | } 70 | 71 | pub fn from_btc(btc: f64) -> Self { 72 | let sats = (btc * Self::SATS_IN_BTC as f64).round() as u64; 73 | Self::from_sats(sats) 74 | } 75 | 76 | pub fn to_btc(self) -> f64 { 77 | self.sats as f64 / Self::SATS_IN_BTC as f64 78 | } 79 | 80 | pub fn from_usd(usd: USD, btcusd_price: f64) -> Self { 81 | let btc = usd.0 / btcusd_price; 82 | Bitcoin::from_btc(btc) 83 | } 84 | 85 | } 86 | 87 | impl Sub for Bitcoin { 88 | type Output = Bitcoin; 89 | 90 | fn sub(self, other: Bitcoin) -> Bitcoin { 91 | Bitcoin::from_sats(self.sats.saturating_sub(other.sats)) 92 | } 93 | } 94 | 95 | impl std::fmt::Display for Bitcoin { 96 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 97 | let btc_value = self.to_btc(); 98 | 99 | // Format the value to 8 decimal places with spaces 100 | let formatted_btc = format!("{:.8}", btc_value); 101 | let with_spaces = formatted_btc 102 | .chars() 103 | .enumerate() 104 | .map(|(i, c)| if i == 4 || i == 7 { format!(" {}", c) } else { c.to_string() }) 105 | .collect::(); 106 | 107 | write!(f, "{} BTC", with_spaces) 108 | } 109 | } 110 | 111 | #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] 112 | pub struct USD(pub f64); 113 | 114 | impl Default for USD { 115 | fn default() -> Self { 116 | Self(0.0) 117 | } 118 | } 119 | 120 | impl USD { 121 | pub fn from_bitcoin(btc: Bitcoin, btcusd_price: f64) -> Self { 122 | Self(btc.to_btc() * btcusd_price) 123 | } 124 | 125 | pub fn from_f64(amount: f64) -> Self { 126 | Self(amount) 127 | } 128 | 129 | pub fn to_msats(self, btcusd_price: f64) -> u64 { 130 | let btc_value = self.0 / btcusd_price; 131 | let sats = btc_value * Bitcoin::SATS_IN_BTC as f64; 132 | let millisats = sats * 1000.0; 133 | millisats.abs().floor() as u64 134 | } 135 | } 136 | 137 | impl Sub for USD { 138 | type Output = USD; 139 | 140 | fn sub(self, other: USD) -> USD { 141 | USD(self.0 - other.0) 142 | } 143 | } 144 | 145 | impl Div for USD { 146 | type Output = USD; 147 | 148 | fn div(self, scalar: f64) -> USD { 149 | USD(self.0 / scalar) 150 | } 151 | } 152 | 153 | impl Div for USD { 154 | type Output = f64; 155 | 156 | fn div(self, other: USD) -> f64 { 157 | self.0 / other.0 158 | } 159 | } 160 | 161 | impl std::fmt::Display for USD { 162 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 163 | write!(f, "${:.2}", self.0) 164 | } 165 | } 166 | 167 | #[derive(Clone, Debug, Serialize, Deserialize)] 168 | pub struct StableChannel { 169 | #[serde(with = "channel_id_serde")] 170 | pub channel_id: ChannelId, 171 | pub is_stable_receiver: bool, 172 | #[serde(with = "pubkey_serde")] 173 | pub counterparty: PublicKey, 174 | pub expected_usd: USD, 175 | pub expected_btc: Bitcoin, 176 | pub stable_receiver_btc: Bitcoin, 177 | pub stable_provider_btc: Bitcoin, 178 | pub stable_receiver_usd: USD, 179 | pub stable_provider_usd: USD, 180 | pub risk_level: i32, 181 | pub timestamp: i64, 182 | pub formatted_datetime: String, 183 | pub payment_made: bool, 184 | pub sc_dir: String, 185 | pub latest_price: f64, 186 | pub prices: String, 187 | 188 | } 189 | 190 | // Implement manual Default for StableChannel 191 | impl Default for StableChannel { 192 | fn default() -> Self { 193 | Self { 194 | channel_id: ChannelId::from_bytes([0; 32]), 195 | is_stable_receiver: true, 196 | counterparty: PublicKey::from_slice(&[2; 33]).unwrap_or_else(|_| { 197 | // This is a fallback that should never be reached, 198 | // but provides a valid default public key if needed 199 | PublicKey::from_slice(&[ 200 | 0x02, 0x50, 0x86, 0x3A, 0xD6, 0x4A, 0x87, 0xAE, 0x8A, 0x2F, 0xE8, 0x3C, 0x1A, 201 | 0xF1, 0xA8, 0x40, 0x3C, 0xB5, 0x3F, 0x53, 0xE4, 0x86, 0xD8, 0x51, 0x1D, 0xAD, 202 | 0x8A, 0x04, 0x88, 0x7E, 0x5B, 0x23, 0x52, 203 | ]).unwrap() 204 | }), 205 | expected_usd: USD(0.0), 206 | expected_btc: Bitcoin::from_sats(0), 207 | stable_receiver_btc: Bitcoin::from_sats(0), 208 | stable_provider_btc: Bitcoin::from_sats(0), 209 | stable_receiver_usd: USD(0.0), 210 | stable_provider_usd: USD(0.0), 211 | risk_level: 0, 212 | timestamp: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64, 213 | formatted_datetime: "".to_string(), 214 | payment_made: false, 215 | sc_dir: ".data".to_string(), 216 | latest_price: 0.0, 217 | prices: "".to_string(), 218 | } 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /stablechannels.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # This is the Core Lightning Python plug-in for Stable Channels - https://github.com/ElementsProject/lightning 4 | 5 | # Stable Channels: p2p BTCUSD trading on Lightning 6 | # Contents 7 | # Section 1 - Dependencies and main data structure 8 | # Section 2 - Price feed config and logic 9 | # Section 3 - Core logic 10 | # Section 4 - Plug-in initialization 11 | 12 | # Section 1 - Dependencies and main data structure 13 | from pyln.client import Plugin # Library for CLN Python plug-ins created by Blockstream 14 | from pyln.client import Millisatoshi # Library for CLN Python plug-ins created by Blockstream 15 | from collections import namedtuple # Standard on Python 3 16 | from cachetools import cached, TTLCache # Used to handle price feed calls; probably can remove 17 | import requests # Standard on Python 3.7+ 18 | from requests.adapters import HTTPAdapter 19 | from requests.packages.urllib3.util.retry import Retry 20 | import statistics # Standard on Python 3 21 | import time # Standard on Python 3 22 | import os 23 | from datetime import datetime 24 | from apscheduler.schedulers.blocking import BlockingScheduler # Used to check balances every 5 minutes 25 | import threading # Standard on Python 3 26 | 27 | plugin = Plugin() 28 | 29 | class StableChannel: 30 | def __init__( 31 | self, 32 | plugin: Plugin, 33 | channel_id: str, 34 | expected_dollar_amount: float, 35 | native_amount_msat: int, 36 | is_stable_receiver: bool, 37 | counterparty: str, 38 | our_balance: float, 39 | their_balance: float, 40 | risk_score: int, 41 | stable_receiver_dollar_amount: float, 42 | stable_provider_dollar_amount: float, 43 | timestamp: int, 44 | formatted_datetime: str, 45 | payment_made: bool, 46 | sc_dir: str 47 | ): 48 | self.plugin = plugin 49 | self.channel_id = channel_id 50 | self.expected_dollar_amount = expected_dollar_amount 51 | self.native_amount_msat = native_amount_msat 52 | self.is_stable_receiver = is_stable_receiver 53 | self.counterparty = counterparty 54 | self.our_balance = our_balance 55 | self.their_balance = their_balance 56 | self.risk_score = risk_score 57 | self.stable_receiver_dollar_amount = stable_receiver_dollar_amount 58 | self.stable_provider_dollar_amount = stable_provider_dollar_amount 59 | self.timestamp = timestamp 60 | self.formatted_datetime = datetime 61 | self.payment_made = payment_made 62 | self.sc_dir = sc_dir 63 | 64 | def __str__(self): 65 | return ( 66 | f"StableChannel(\n" 67 | f" channel_id={self.channel_id},\n" 68 | f" expected_dollar_amount={self.expected_dollar_amount},\n" 69 | f" native_amount_msat={self.native_amount_msat},\n" 70 | f" is_stable_receiver={self.is_stable_receiver},\n" 71 | f" counterparty={self.counterparty},\n" 72 | f" our_balance={self.our_balance},\n" 73 | f" their_balance={self.their_balance},\n" 74 | f" risk_score={self.risk_score},\n" 75 | f" stable_receiver_dollar_amount={self.stable_receiver_dollar_amount},\n" 76 | f" stable_provider_dollar_amount={self.stable_provider_dollar_amount},\n" 77 | f" timestamp={self.timestamp},\n" 78 | f" formatted_datetime={self.formatted_datetime},\n" 79 | f" payment_made={self.payment_made}\n" 80 | f" sc_dir={self.sc_dir}\n" 81 | f")" 82 | ) 83 | 84 | # Section 2 - Price feed config and logic 85 | Source = namedtuple('Source', ['name', 'urlformat', 'replymembers']) 86 | 87 | # 5 price feed sources 88 | sources = [ 89 | # e.g. {"high": "18502.56", "last": "17970.41", "timestamp": "1607650787", "bid": "17961.87", "vwap": "18223.42", "volume": "7055.63066541", "low": "17815.92", "ask": "17970.41", "open": "18250.30"} 90 | Source('bitstamp', 91 | 'https://www.bitstamp.net/api/v2/ticker/btc{currency_lc}/', 92 | ['last']), 93 | # e.g. {"bitcoin":{"usd":17885.84}} 94 | Source('coingecko', 95 | 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies={currency_lc}', 96 | ['bitcoin', '{currency_lc}']), 97 | # e.g. {"time":{"updated":"Dec 16, 2020 00:58:00 UTC","updatedISO":"2020-12-16T00:58:00+00:00","updateduk":"Dec 16, 2020 at 00:58 GMT"},"disclaimer":"This data was produced from the CoinDesk Bitcoin Price Index (USD). Non-USD currency data converted using hourly conversion rate from openexchangerates.org","bpi":{"USD":{"code":"USD","rate":"19,395.1400","description":"United States Dollar","rate_float":19395.14},"AUD":{"code":"AUD","rate":"25,663.5329","description":"Australian Dollar","rate_float":25663.5329}}} 98 | # Source('coindesk', 99 | # 'https://api.coindesk.com/v1/bpi/currentprice/{currency}.json', 100 | # ['bpi', '{currency}', 'rate_float']), 101 | # e.g. {"data":{"base":"BTC","currency":"USD","amount":"19414.63"}} 102 | Source('coinbase', 103 | 'https://api.coinbase.com/v2/prices/spot?currency={currency}', 104 | ['data', 'amount']), 105 | # e.g. { "USD" : {"15m" : 6650.3, "last" : 6650.3, "buy" : 6650.3, "sell" : 6650.3, "symbol" : "$"}, "AUD" : {"15m" : 10857.19, "last" : 10857.19, "buy" : 10857.19, "sell" : 10857.19, "symbol" : "$"},... 106 | Source('blockchain.info', 107 | 'https://blockchain.info/ticker', 108 | ['{currency}', 'last']), 109 | ] 110 | 111 | # Request logic is from "currencyrate" plugin: 112 | # https://github.com/lightningd/plugins/blob/master/currencyrate 113 | def requests_retry_session( 114 | retries=3, 115 | backoff_factor=0.3, 116 | status_forcelist=(500, 502, 504), 117 | session=None, 118 | ): 119 | session = session or requests.Session() 120 | retry = Retry( 121 | total=retries, 122 | read=retries, 123 | connect=retries, 124 | backoff_factor=backoff_factor, 125 | status_forcelist=status_forcelist, 126 | ) 127 | adapter = HTTPAdapter(max_retries=retry) 128 | session.mount('http://', adapter) 129 | session.mount('https://', adapter) 130 | return session 131 | 132 | def get_currencyrate(plugin, currency, urlformat, replymembers): 133 | # NOTE: Bitstamp has a DNS/Proxy issues that can return 404 134 | # Workaround: retry up to 5 times with a delay 135 | currency_lc = currency.lower() 136 | url = urlformat.format(currency_lc=currency_lc, currency=currency) 137 | r = requests_retry_session(retries=5, status_forcelist=[404]).get(url, proxies=plugin.proxies) 138 | 139 | if r.status_code != 200: 140 | plugin.log(level='info', message='{}: bad response {}'.format(url, r.status_code)) 141 | return None 142 | 143 | json = r.json() 144 | for m in replymembers: 145 | expanded = m.format(currency_lc=currency_lc, currency=currency) 146 | if expanded not in json: 147 | plugin.log(level='debug', message='{}: {} not in {}'.format(url, expanded, json)) 148 | return None 149 | json = json[expanded] 150 | 151 | try: 152 | return Millisatoshi(int(10**11 / float(json))) 153 | except Exception: 154 | plugin.log(level='info', message='{}: could not convert {} to msat'.format(url, json)) 155 | return None 156 | 157 | def set_proxies(plugin): 158 | config = plugin.rpc.listconfigs() 159 | if 'always-use-proxy' in config and config['always-use-proxy']: 160 | paddr = config['proxy'] 161 | # Default port in 9050 162 | if ':' not in paddr: 163 | paddr += ':9050' 164 | plugin.proxies = {'https': 'socks5h://' + paddr, 165 | 'http': 'socks5h://' + paddr} 166 | else: 167 | plugin.proxies = None 168 | 169 | # Cache returns cached result if <60 seconds old. 170 | # Stable Channels may not need 171 | @cached(cache=TTLCache(maxsize=1024, ttl=60)) 172 | def get_rates(plugin, currency): 173 | rates = {} 174 | for s in sources: 175 | r = get_currencyrate(plugin, currency, s.urlformat, s.replymembers) 176 | if r is not None: 177 | rates[s.name] = r 178 | 179 | plugin.log(level="debug", message=f"rates line 165 {rates}") 180 | return rates 181 | 182 | @plugin.method("currencyconvert") 183 | def currencyconvert(plugin, amount, currency): 184 | """Converts currency using given APIs.""" 185 | rates = get_rates(plugin, currency.upper()) 186 | if len(rates) == 0: 187 | raise Exception("No values available for currency {}".format(currency.upper())) 188 | 189 | val = statistics.median([m.millisatoshis for m in rates.values()]) * float(amount) 190 | 191 | estimated_price = "{:.2f}".format(100000000000 / statistics.median([m.millisatoshis for m in rates.values()])) 192 | 193 | return ({"msat": Millisatoshi(round(val))}, estimated_price) 194 | 195 | 196 | def msats_to_currency(msats, rate_currency_per_btc): 197 | msats = float(msats) 198 | rate_currency_per_btc = float(rate_currency_per_btc) 199 | return msats / 1e11 * rate_currency_per_btc 200 | 201 | # Section 3 - Core logic 202 | 203 | # This function is the scheduler, formatted to fire every 5 minutes 204 | # This begins your regularly scheduled programming 205 | def start_scheduler(plugin, sc): 206 | scheduler = BlockingScheduler() 207 | scheduler.add_job(check_stables, 'cron', minute='0/5', args=[plugin, sc]) 208 | scheduler.start() 209 | 210 | # 5 scenarios to handle 211 | # Scenario 1 - Difference to small to worry about (under $0.01) = do nothing 212 | # Scenario 2 - Node is stableReceiver and expects to get paid = wait 30 seconds; check on payment 213 | # Scenario 3 - Node is stableProvider and needs to pay = keysend and exit 214 | # Scenario 4 - Node is stableReceiver and needs to pay = keysend and exit 215 | # Scenario 5 - Node is stableProvider and expects to get paid = wait 30 seconds; check on payment 216 | # "sc" = "Stable Channel" object 217 | def check_stables(plugin, sc): 218 | 219 | msat_dict, estimated_price = currencyconvert(plugin, sc.expected_dollar_amount, "USD") 220 | 221 | expected_msats = msat_dict["msat"] 222 | 223 | # Get channel data 224 | list_funds_data = plugin.rpc.listfunds() 225 | channels = list_funds_data.get("channels", []) 226 | 227 | # Find the correct stable channel and set balances 228 | for channel in channels: 229 | if channel.get("channel_id") == sc.channel_id: 230 | sc.our_balance = channel.get("our_amount_msat") 231 | sc.their_balance = Millisatoshi.__sub__(channel.get("amount_msat"), sc.our_balance) 232 | 233 | # Get Stable Receiver dollar amount 234 | if sc.is_stable_receiver: 235 | sc.stable_receiver_dollar_amount = round((int((sc.our_balance - sc.native_amount_msat) * sc.expected_dollar_amount)) / int(expected_msats), 3) 236 | else: 237 | sc.stable_receiver_dollar_amount = round((int((sc.their_balance - sc.native_amount_msat) * sc.expected_dollar_amount)) / int(expected_msats), 3) 238 | 239 | formatted_time = datetime.utcnow().strftime("%H:%M %d %b %Y") 240 | 241 | sc.payment_made = False 242 | amount_too_small = False 243 | 244 | plugin.log (sc.__str__()) 245 | 246 | # Scenario 1 - Difference to small to worry about (under $0.01) = do nothing 247 | if abs(sc.expected_dollar_amount - float(sc.stable_receiver_dollar_amount)) < 0.01: 248 | amount_too_small = True 249 | else: 250 | # Round difference to nearest msat; we may need to pay it 251 | if sc.is_stable_receiver: 252 | may_need_to_pay_amount = round(abs(int(expected_msats + sc.native_amount_msat) - int(sc.our_balance))) 253 | else: 254 | may_need_to_pay_amount = round(abs(int(expected_msats + sc.native_amount_msat) - int(sc.their_balance))) 255 | 256 | # USD price went down. 257 | if not amount_too_small and (sc.stable_receiver_dollar_amount < sc.expected_dollar_amount): 258 | # Scenario 2 - Node is stableReceiver and expects to get paid = wait 30 seconds; check on payment 259 | if sc.is_stable_receiver: 260 | time.sleep(30) 261 | 262 | list_funds_data = plugin.rpc.listfunds() 263 | 264 | # We should have payment now; check that amount is within 1 penny 265 | channels = list_funds_data.get("channels", []) 266 | 267 | for channel in channels: 268 | if channel.get("channel_id") == sc.channel_id: 269 | plugin.log("Found Stable Channel") 270 | new_our_stable_balance_msat = channel.get("our_amount_msat") - sc.native_amount_msat 271 | else: 272 | plugin.log("Could not find channel") 273 | 274 | new_stable_receiver_dollar_amount = round((int(new_our_stable_balance_msat) * sc.expected_dollar_amount) / int(expected_msats), 3) 275 | 276 | if sc.expected_dollar_amount - float(new_stable_receiver_dollar_amount) < 0.01: 277 | sc.payment_made = True 278 | else: 279 | # Increase risk score 280 | sc.risk_score = sc.risk_score + 1 281 | 282 | 283 | elif not(sc.is_stable_receiver): 284 | # Scenario 3 - Node is stableProvider and needs to pay = keysend and exit 285 | plugin.rpc.keysend(sc.counterparty,may_need_to_pay_amount) 286 | 287 | # TODO - error handling 288 | sc.payment_made = True 289 | 290 | elif amount_too_small: 291 | sc.payment_made = False 292 | 293 | # USD price went up 294 | # TODO why isnt expected_dollar_amount being a float? 295 | elif not amount_too_small and sc.stable_receiver_dollar_amount > sc.expected_dollar_amount: 296 | # 4 - Node is stableReceiver and needs to pay = keysend 297 | if sc.is_stable_receiver: 298 | plugin.rpc.keysend(sc.counterparty,may_need_to_pay_amount) 299 | 300 | # TODO - error handling 301 | sc.payment_made = True 302 | 303 | # Scenario 5 - Node is stableProvider and expects to get paid = wait 30 seconds; check on payment 304 | elif not(sc.is_stable_receiver): 305 | time.sleep(30) 306 | 307 | list_funds_data = plugin.rpc.listfunds() 308 | 309 | channels = list_funds_data.get("channels", []) 310 | 311 | for channel in channels: 312 | if channel.get("channel_id") == sc.channel_id: 313 | plugin.log("Found Stable Channel") 314 | else: 315 | plugin.log("Could not find Stable Channel") 316 | 317 | # We should have payment now; check amount is within 1 penny 318 | new_our_balance = channel.get("our_amount_msat") 319 | new_their_stable_balance_msat = Millisatoshi.__sub__(channel.get("amount_msat"), new_our_balance) - sc.native_amount_msat 320 | 321 | new_stable_receiver_dollar_amount = round((int(new_their_stable_balance_msat) * sc.expected_dollar_amount) / int(expected_msats), 3) 322 | 323 | if sc.expected_dollar_amount - float(new_stable_receiver_dollar_amount) < 0.01: 324 | sc.payment_made = True 325 | else: 326 | # Increase risk score 327 | sc.risk_score = sc.risk_score + 1 328 | 329 | # We write this to the main ouput file. 330 | json_line = f'{{"formatted_time": "{formatted_time}", "estimated_price": {estimated_price}, "expected_dollar_amount": {sc.expected_dollar_amount}, "stable_receiver_dollar_amount": {sc.stable_receiver_dollar_amount}, "payment_made": {sc.payment_made}, "risk_score": {sc.risk_score}}},\n' 331 | 332 | # Log the result 333 | # How to log better? 334 | plugin.log(json_line) 335 | if sc.is_stable_receiver: 336 | file_path = os.path.join(sc.sc_dir, "stablelog1.json") 337 | 338 | with open(file_path, 'a') as file: 339 | file.write(json_line) 340 | 341 | elif not(sc.is_stable_receiver): 342 | file_path = os.path.join(sc.sc_dir, "stablelog2.json") 343 | 344 | with open(file_path, 'a') as file: 345 | file.write(json_line) 346 | 347 | # this method updates the balances in memory 348 | def handle_coin_movement(plugin, sc, *args, **kwargs): 349 | 350 | coin_movement = kwargs.get('coin_movement', {}) 351 | version = coin_movement.get('version') 352 | node_id = coin_movement.get('node_id') 353 | type_ = coin_movement.get('type') 354 | account_id = coin_movement.get('account_id') 355 | payment_hash = coin_movement.get('payment_hash') 356 | part_id = coin_movement.get('part_id') 357 | credit_msat = coin_movement.get('credit_msat') 358 | debit_msat = coin_movement.get('debit_msat') 359 | fees_msat = coin_movement.get('fees_msat') 360 | tags = coin_movement.get('tags', []) 361 | timestamp = coin_movement.get('timestamp') 362 | coin_type = coin_movement.get('coin_type') 363 | 364 | # Print or manipulate the extracted values as needed 365 | plugin.log(f"Version:{version}") 366 | plugin.log(f"Node ID:{node_id}") 367 | plugin.log(f"Type:{type_}") 368 | plugin.log(f"Account ID:{account_id}") 369 | plugin.log(f"Payment Hash:{payment_hash}") 370 | plugin.log(f"Part ID:{part_id}") 371 | plugin.log(f"Credit Millisatoshi:{credit_msat}") 372 | plugin.log(f"Debit Millisatoshi:{debit_msat}") 373 | plugin.log(f"Fees Millisatoshi:{fees_msat}") 374 | plugin.log(f"Tags:{tags}") 375 | plugin.log(f"Timestamp:{timestamp}") 376 | plugin.log(f"Coin Type:{coin_type}") 377 | 378 | if sc.channel_id == account_id: 379 | # if a payment has been routed out of this account (channel) 380 | # then this means we are the Stable Provider 381 | # and we need to adjust the Stable Balance downwards 382 | if 'routed' in tags: 383 | 384 | # the Stable Provider routed a pay out 385 | if credit_msat > 0: 386 | # need to convert msats to dollars 387 | plugin.log(f"previous stable dollar amount:{sc.expected_dollar_amount}") 388 | msat_dict, estimated_price = currencyconvert(plugin, sc.expected_dollar_amount, "USD") 389 | currency_units = msats_to_currency(int(credit_msat), estimated_price) 390 | sc.expected_dollar_amount -= currency_units 391 | plugin.log(f"estimated_price:{estimated_price}") 392 | plugin.log(f"post stable_dollar_amount:{sc.expected_dollar_amount}", ) 393 | 394 | # the SR got paid 395 | if debit_msat > 0: 396 | plugin.log("shall debit, somehow") 397 | # sc.our_balance = sc.our_balance + credit_msat 398 | 399 | if 'invoice' in tags: 400 | # We need to check the payment destination is NOT the counterparty 401 | # Because CLN also records keysends as 'invoice' 402 | listpays_data = plugin.rpc.listpays(payment_hash=payment_hash) 403 | 404 | if listpays_data and listpays_data["pays"]: 405 | destination = listpays_data["pays"][0]["destination"] 406 | 407 | # If the counterparty is not the destination, 408 | # Thne it is a payment out, probably made by Stable Receiver 409 | if sc.counterparty != destination: 410 | 411 | # if the we are the dest then we received, increase expected dollar value 412 | if credit_msat > 0: 413 | plugin.log("shall credit somehow") 414 | 415 | #sc.stable_dollar_amount += credit_msat 416 | 417 | # the Stable Receiver paid an invoice 418 | if debit_msat > 0: 419 | plugin.log(f"previous stable dollar amount:{sc.expected_dollar_amount}") 420 | msat_dict, estimated_price = currencyconvert(plugin, sc.expected_dollar_amount, "USD") 421 | debit_plus_fees = debit_msat + fees_msat 422 | currency_units = msats_to_currency(int(debit_plus_fees), estimated_price) 423 | sc.expected_dollar_amount -= currency_units 424 | plugin.log(f"estimated_price:{estimated_price}") 425 | plugin.log(f"currency_units:{currency_units}") 426 | plugin.log(f"post stable_dollar_amount:{sc.expected_dollar_amount}") 427 | # sc.our_balance = sc.our_balance + credit_msat 428 | 429 | def parse_boolean(value): 430 | if isinstance(value, bool): 431 | return value 432 | if isinstance(value, str): 433 | value_lower = value.strip().lower() 434 | if value_lower in {'true', 'yes', '1'}: 435 | return True 436 | elif value_lower in {'false', 'no', '0'}: 437 | return False 438 | raise ValueError(f"Invalid boolean value: {value}") 439 | 440 | @plugin.method("dev-check-stable") 441 | def dev_check_stable(plugin): 442 | # immediately run check_stable, but only if we are a dev 443 | # this can be used in tests and maybe to pass artificial currency rate changes 444 | dev = plugin.rpc.listconfigs("developer")["configs"]["developer"]["set"] 445 | if dev: 446 | check_stables(plugin, sc) 447 | return {"result": "OK"} 448 | else: 449 | raise Exception("ERROR: not a --developer") 450 | 451 | # Section 4 - Plug-in initialization 452 | @plugin.init() 453 | def init(options, configuration, plugin): 454 | set_proxies(plugin) 455 | 456 | plugin.log(level="debug", message=options['is-stable-receiver']) 457 | 458 | # Need to handle boolean input this way 459 | is_stable_receiver = parse_boolean(options['is-stable-receiver']) 460 | 461 | # convert to millsatoshis ... 462 | if int(options['native-btc-amount']) > 0: 463 | native_btc_amt_msat = int(options['native-btc-amount']) * 1000 464 | else: 465 | native_btc_amt_msat = 0 466 | 467 | lightning_dir = plugin.rpc.getinfo()["lightning-dir"] 468 | sc_dir = os.path.join(lightning_dir, "stablechannels") 469 | os.makedirs(sc_dir, exist_ok=True) 470 | 471 | global sc 472 | sc = StableChannel( 473 | plugin=plugin, 474 | channel_id=options['channel-id'], 475 | expected_dollar_amount=float(options['stable-dollar-amount']), 476 | native_amount_msat=native_btc_amt_msat, 477 | is_stable_receiver=is_stable_receiver, 478 | counterparty=options['counterparty'], 479 | our_balance=0, 480 | their_balance=0, 481 | risk_score=0, 482 | stable_receiver_dollar_amount=0, 483 | stable_provider_dollar_amount=0, 484 | timestamp=0, 485 | formatted_datetime='', 486 | payment_made=False, 487 | sc_dir=sc_dir 488 | ) 489 | 490 | plugin.log("Starting Stable Channel with these details:") 491 | plugin.log(sc.__str__()) 492 | 493 | # Need to start a new thread so init funciotn can return 494 | threading.Thread(target=start_scheduler, args=(plugin, sc)).start() 495 | 496 | plugin.add_option(name='channel-id', default='', description='Input the channel ID you wish to stabilize.') 497 | plugin.add_option(name='is-stable-receiver', default='', description='Input True if you are the Stable Receiever; False if you are the Stable Provider.') 498 | plugin.add_option(name='stable-dollar-amount', default='', description='Input the amount of dollars you want to keep stable.') 499 | plugin.add_option(name='native-btc-amount', default='', description='Input the amount of bitcoin you do not want to be kept stable, in sats.') 500 | plugin.add_option(name='counterparty', default='', description='Input the nodeID of your counterparty.') 501 | 502 | plugin.add_subscription("coin_movement", lambda *args, **kwargs: handle_coin_movement(plugin, sc, *args, **kwargs)) 503 | 504 | plugin.run() 505 | -------------------------------------------------------------------------------- /test_stablechannels.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from pyln.testing.fixtures import * # noqa: F403 5 | from pyln.testing.utils import sync_blockheight 6 | 7 | LOGGER = logging.getLogger(__name__) 8 | 9 | PLUGIN_PATH = os.path.join(os.path.dirname(__file__), "./stablechannels.py") 10 | 11 | 12 | def test_start(node_factory, bitcoind): 13 | l1, l2 = node_factory.get_nodes( 14 | 2, 15 | opts={"experimental-dual-fund": None}, 16 | ) 17 | funder = l2.rpc.funderupdate( 18 | policy="match", 19 | policy_mod=100, 20 | leases_only=True, 21 | lease_fee_base_msat=2_000, 22 | lease_fee_basis=10, 23 | channel_fee_max_base_msat=1000, 24 | channel_fee_max_proportional_thousandths=2, 25 | ) 26 | l1.fundwallet(10_000_000) 27 | l2.fundwallet(10_000_000) 28 | 29 | cl1 = l1.rpc.fundchannel( 30 | l2.info["id"] + "@localhost:" + str(l2.port), 31 | 1_000_000, 32 | request_amt=1_000_000, 33 | compact_lease=funder["compact_lease"], 34 | ) 35 | bitcoind.generate_block(6) 36 | sync_blockheight(bitcoind, [l1, l2]) 37 | 38 | # configs = l1.rpc.listconfigs()["configs"] 39 | l1.rpc.plugin_start( 40 | PLUGIN_PATH, 41 | **{ 42 | "channel-id": cl1["channel_id"], 43 | "is-stable-receiver": False, 44 | "stable-dollar-amount": 400, 45 | "native-btc-amount": 500_000, 46 | "counterparty": l2.info["id"], 47 | }, 48 | ) 49 | l1.daemon.wait_for_log("Starting Stable Channel with these details") 50 | invoice = l2.rpc.invoice(1_000_000, "label1", "desc") 51 | l1.rpc.pay(invoice["bolt11"]) 52 | 53 | invoice = l1.rpc.invoice(1_000_000, "label1", "desc") 54 | l2.rpc.pay(invoice["bolt11"]) 55 | 56 | l1.rpc.call("dev-check-stable") 57 | 58 | 59 | --------------------------------------------------------------------------------