Compare commits

...

10 Commits

Author SHA1 Message Date
Keyan
40131da8bd
Update awards.csv 2024-04-14 18:22:08 -05:00
keyan
72fb8a490c refine readme a bit 2024-04-14 18:02:13 -05:00
keyan
75771bfb7e remove deprecated version from readme override example 2024-04-14 17:44:19 -05:00
keyan
40e07be6d8 update sndev docs and auto-start for capture service 2024-04-14 17:43:55 -05:00
keyan
b05c1b6734 update README with up to date sndev help output 2024-04-14 17:42:39 -05:00
keyan
51f1c08a7e get rid of docker compose version number deprecation warning 2024-04-14 17:40:52 -05:00
keyan
d3f18c7cff get rid of docker nagware 2024-04-14 17:40:24 -05:00
ekzyis
9f4d5e13aa
CLN autowithdrawal (#1042)
* Add CLN node to docker-compose.yml

* Attach CLN wallet via CLNRest

* Remove leading space

* Implement autowithdrawal to CLN in worker

* Fix UnhandledSchemeError during build

See https://github.com/vercel/next.js/discussions/33982

* Refactor CLN invoice code into @/lib/cln

* Fix missing env vars

* Fix validation error if rune invalid

* Update header

* Add rune placeholder

* Fix missing expiry for test invoice

* Remove nonsensical comment

* Remove unnecessary async

* Show level SUCCESS as OK in logs

* Add stacker_cln commands to sndev

* fix sndev posix compliance, add cln_withdraw

* give stacker_cln larger channels

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2024-04-14 17:34:21 -05:00
Ben Allen
3aee93ee16
add media query sizing for rewards pie chart (#1068) 2024-04-14 16:28:09 -05:00
Ben Allen
de24b4a037
use the zindex-sticky variable (#1066) 2024-04-14 13:26:41 -05:00
32 changed files with 841 additions and 40 deletions

View File

@ -137,6 +137,12 @@ STACKER_LND_GRPC_PORT=10010
STACKER_LND_ADDR=bcrt1qfqau4ug9e6rtrvxrgclg58e0r93wshucumm9vu
STACKER_LND_PUBKEY=028093ae52e011d45b3e67f2e0f2cb6c3a1d7f88d2920d408f3ac6db3a56dc4b35
# stacker cln container stuff
STACKER_CLN_REST_PORT=9092
# docker exec -u clightning stacker_cln lightning-cli newaddr bech32
STACKER_CLN_ADDR=bcrt1q02sqd74l4pxedy24fg0qtjz4y2jq7x4lxlgzrx
STACKER_CLN_PUBKEY=03ca7acec181dbf5e427c682c4261a46a0dd9ea5f35d97acb094e399f727835b90
LNCLI_NETWORK=regtest
# localstack container stuff

View File

@ -70,6 +70,7 @@ COMMANDS
stop stop env
restart restart env
status status of env
logs logs from env
delete delete env
sn:
@ -79,6 +80,10 @@ COMMANDS
fund pay a bolt11 for funding
withdraw create a bolt11 for withdrawal
cln:
cln_fund pay a bolt11 for funding with CLN
cln_withdraw create a bolt11 for withdrawal with CLN
db:
psql open psql on db
prisma run prisma commands
@ -88,9 +93,10 @@ COMMANDS
lint run linters
other:
compose docker compose passthrough
sn_lncli lncli passthrough on sn_lnd
stacker_lncli lncli passthrough on stacker_lnd
compose docker compose passthrough
sn_lndcli lncli passthrough on sn_lnd
stacker_lndcli lncli passthrough on stacker_lnd
stacker_clncli lightning-cli passthrough on stacker_cln
```
@ -98,7 +104,7 @@ COMMANDS
#### Running specific services
By default all services will be run. If you want to exclude specific services from running, set `COMPOSE_PROFILES` to use one or more of `minimal|images|search|payments|email`. To only run mininal services without images, search, or payments:
By default all services will be run. If you want to exclude specific services from running, set `COMPOSE_PROFILES` to use one or more of `minimal|images|search|payments|email|capture`. To only run mininal services without images, search, or payments:
```sh
$ COMPOSE_PROFILES=minimal ./sndev start
@ -124,7 +130,6 @@ By default `sndev start` will merge `docker-compose.yml` with `docker-compose.ov
For example, if you want to replace the db seed with a custom seed file located in `docker/db/another.sql`, you'd create a `docker-compose.override.yml` file with the following:
```yml
version: "3"
services:
db:
volumes:
@ -152,6 +157,11 @@ You can read more about [docker compose override files](https://docs.docker.com/
- [Responsible disclosure of security or privacy vulnerability awards](#responsible-disclosure-of-security-or-privacy-vulnerability-awards)
- [Development documentation awards](#development-documentation-awards)
- [Helpfulness awards](#helpfulness-awards)
- [Contribution extras](#contribution-extras)
- [Dev chat](#dev-chat)
- [Triage permissions](#triage-permissions)
- [Contributor badges on SN profiles](#contributor-badges-on-sn-profiles)
- [What else you got](#what-else-you-got)
- [Development Tips](#development-tips)
- [Linting](#linting)
- [Database migrations](#database-migrations)
@ -227,6 +237,9 @@ _Due to Rule 3, make sure that you mark your PR as a draft when you create it an
| `priority:high` | 2 |
| `priority:urgent` | 3 |
### Requesting modifications to reward amounts
We try to assign difficulty and priority tags to issues accurately, but we're not perfect. If you believe an issue is mis-tagged, you can request a change to the issue's tags.
<br>
## Code review awards
@ -304,6 +317,25 @@ Like issue specification awards, helping fellow contributors substantially in a
<br>
# Contribution extras
We want to make contributing to SN as rewarding as possible, so we offer a few extras to contributors.
## Dev chat
We self-host a private chat server for contributors to SN. If you'd like to join, please respond in this [discussion](https://github.com/stackernews/stacker.news/discussions/1059).
## Triage permissions
We offer triage permissions to contributors after they've made a few contributions. I'll usually add them as I notice people contributing, but if I missed you and you'd like to be added, let me know!
## Contributor badges on SN profiles
Contributors can get badges on their SN profiles by opening a pull request adding their SN nym to the [contributors.txt](/contributors.txt) file.
## What else you got
In the future we plan to offer more, like gratis github copilot subscriptions, reverse tunnels, codespaces, and merch.
If you'd like to see something added, please make a suggestion.
<br>
# Development Tips
<br>
@ -432,7 +464,7 @@ To ensure stackers balances are kept sane, all wallet updates are run in [serial
<br>
# Need help?
Open a [discussion](http://github.com/stackernews/stacker.news/discussions) or [issue](http://github.com/stackernews/stacker.news/issues/new) or [email us](mailto:kk@stacker.news) or [chat with us on telegram](https://t.me/stackernews).
Open a [discussion](http://github.com/stackernews/stacker.news/discussions) or [issue](http://github.com/stackernews/stacker.news/issues/new) or [email us](mailto:kk@stacker.news) or request joining the [dev chat](#dev-chat).
<br>

View File

@ -7,11 +7,12 @@ import lnpr from 'bolt11'
import { SELECT } from './item'
import { lnAddrOptions } from '@/lib/lnurl'
import { msatsToSats, msatsToSatsDecimal, ensureB64 } from '@/lib/format'
import { LNDAutowithdrawSchema, amountSchema, lnAddrAutowithdrawSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '@/lib/validate'
import { CLNAutowithdrawSchema, LNDAutowithdrawSchema, amountSchema, lnAddrAutowithdrawSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '@/lib/validate'
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, ANON_USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT } from '@/lib/constants'
import { datePivot } from '@/lib/time'
import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey'
import { createInvoice as createInvoiceCLN } from '@/lib/cln'
export async function getInvoice (parent, { id }, { me, models, lnd }) {
const inv = await models.invoice.findUnique({
@ -323,7 +324,7 @@ export default {
},
WalletDetails: {
__resolveType (wallet) {
return wallet.address ? 'WalletLNAddr' : 'WalletLND'
return wallet.address ? 'WalletLNAddr' : wallet.macaroon ? 'WalletLND' : 'WalletCLN'
}
},
Mutation: {
@ -466,6 +467,36 @@ export default {
},
{ settings, data }, { me, models })
},
upsertWalletCLN: async (parent, { settings, ...data }, { me, models }) => {
data.cert = ensureB64(data.cert)
const wallet = 'walletCLN'
return await upsertWallet(
{
schema: CLNAutowithdrawSchema,
walletName: wallet,
walletType: 'CLN',
testConnect: async ({ socket, rune, cert }) => {
try {
const inv = await createInvoiceCLN({
socket,
rune,
cert,
description: 'SN connection test',
msats: 'any',
expiry: 0
})
await addWalletLog({ wallet, level: 'SUCCESS', message: 'connected to CLN' }, { me, models })
return inv
} catch (err) {
const details = err.details || err.message || err.toString?.()
await addWalletLog({ wallet, level: 'ERROR', message: `could not connect to CLN: ${details}` }, { me, models })
throw err
}
}
},
{ settings, data }, { me, models })
},
upsertWalletLNAddr: async (parent, { settings, ...data }, { me, models }) => {
const wallet = 'walletLightningAddress'
return await upsertWallet(
@ -495,6 +526,8 @@ export default {
let walletName = ''
if (wallet.type === 'LND') {
walletName = 'walletLND'
} else if (wallet.type === 'CLN') {
walletName = 'walletCLN'
} else if (wallet.type === 'LIGHTNING_ADDRESS') {
walletName = 'walletLightningAddress'
}

View File

@ -20,6 +20,7 @@ export default gql`
cancelInvoice(hash: String!, hmac: String!): Invoice!
dropBolt11(id: ID): Withdrawl
upsertWalletLND(id: ID, socket: String!, macaroon: String!, cert: String, settings: AutowithdrawSettings!): Boolean
upsertWalletCLN(id: ID, socket: String!, rune: String!, cert: String, settings: AutowithdrawSettings!): Boolean
upsertWalletLNAddr(id: ID, address: String!, settings: AutowithdrawSettings!): Boolean
removeWallet(id: ID!): Boolean
}
@ -42,7 +43,13 @@ export default gql`
cert: String
}
union WalletDetails = WalletLNAddr | WalletLND
type WalletCLN {
socket: String!
rune: String!
cert: String
}
union WalletDetails = WalletLNAddr | WalletLND | WalletCLN
input AutowithdrawSettings {
autoWithdrawThreshold: Int!

View File

@ -50,3 +50,6 @@ benalleng,pr,#1050,,good-first-issue,,,,20k,???,???
jp30566347,pr,#1055,#771,medium,,,extra mile,300k,jpmelanson@getalby.com,2024-04-12
benalleng,helpfulness,#1063,202,medium,,,did much of the legwork in another pr,100k,???,???
benalleng,code review,#1063,202,medium,,,,25k,???,???
benalleng,pr,#1066,#1060,good-first-issue,,,,20k,???,???
benalleng,pr,#1068,#1067,good-first-issue,,,,20k,???,???
abhiShandy,helpfulness,#1068,#1067,good-first-issue,,,,2k,abhishandy@stacker.news,2024-04-14

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
50 jp30566347 pr #1055 #771 medium extra mile 300k jpmelanson@getalby.com 2024-04-12
51 benalleng helpfulness #1063 202 medium did much of the legwork in another pr 100k ??? ???
52 benalleng code review #1063 202 medium 25k ??? ???
53 benalleng pr #1066 #1060 good-first-issue 20k ??? ???
54 benalleng pr #1068 #1067 good-first-issue 20k ??? ???
55 abhiShandy helpfulness #1068 #1067 good-first-issue 2k abhishandy@stacker.news 2024-04-14

View File

@ -8,7 +8,7 @@ export default function LogMessage ({ wallet, level, message, ts }) {
<tr className={styles.line}>
<td className={styles.timestamp}>{timeSince(new Date(ts))}</td>
<td className={styles.wallet}>[{wallet}]</td>
<td className={`${styles.level} ${levelClassName}`}>{level}</td>
<td className={`${styles.level} ${levelClassName}`}>{level === 'success' ? 'ok' : level}</td>
<td>{message}</td>
</tr>
)

View File

@ -160,6 +160,7 @@ const initIndexedDB = async (storeName) => {
const renameWallet = (wallet) => {
if (wallet === 'walletLightningAddress') return 'lnAddr'
if (wallet === 'walletLND') return 'lnd'
if (wallet === 'walletCLN') return 'cln'
return wallet
}

View File

@ -1,4 +1,3 @@
version: "3"
services:
db:
container_name: db
@ -253,14 +252,17 @@ services:
bash -c '
blockcount=$$(bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} getblockcount 2>/dev/null)
if (( blockcount <= 0 )); then
echo "Mining 10 blocks to sn_lnd and stacker_lnd..."
echo "Mining 10 blocks to sn_lnd, stacker_lnd, stacker_cln..."
bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} generatetoaddress 100 ${LND_ADDR}
bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} generatetoaddress 100 ${STACKER_LND_ADDR}
bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} generatetoaddress 100 ${STACKER_CLN_ADDR}
else
echo "Mining a block to sn_lnd... ${LND_ADDR}"
bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} generatetoaddress 1 ${LND_ADDR}
echo "Mining a block to stacker_lnd... ${STACKER_LND_ADDR}"
bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} generatetoaddress 1 ${STACKER_LND_ADDR}
echo "Mining a block to stacker_cln... ${STACKER_CLN_ADDR}"
bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} generatetoaddress 1 ${STACKER_CLN_ADDR}
fi
'
sn_lnd:
@ -381,8 +383,8 @@ services:
- stacker_lnd:/home/lnd/.lnd
labels:
ofelia.enabled: "true"
ofelia.job-exec.stacker_channel_cron.schedule: "@every 1m"
ofelia.job-exec.stacker_channel_cron.command: >
ofelia.job-exec.stacker_lnd_channel_cron.schedule: "@every 1m"
ofelia.job-exec.stacker_lnd_channel_cron.command: >
su lnd -c bash -c "
if [ $$(lncli getinfo | jq '.num_active_channels + .num_pending_channels') -ge 3 ]; then
exit 0
@ -391,6 +393,55 @@ services:
--min_confs 0 --local_amt=1000000000 --push_amt=500000000
fi
"
stacker_cln:
build:
context: ./docker/cln
container_name: stacker_cln
restart: unless-stopped
profiles:
- payments
healthcheck:
test: ["CMD-SHELL", "su clightning -c 'lightning-cli --network=regtest getinfo'"]
interval: 10s
timeout: 10s
retries: 10
start_period: 1m
depends_on:
bitcoin:
condition: service_healthy
restart: true
env_file:
- .env.development
command:
- 'lightningd'
- '--network=regtest'
- '--alias=stacker_cln'
- '--bitcoin-rpcconnect=bitcoin'
- '--bitcoin-rpcuser=${RPC_USER}'
- '--bitcoin-rpcpassword=${RPC_PASS}'
- '--large-channels'
- '--rest-port=3010'
- '--rest-host=0.0.0.0'
- '--log-file=/home/clightning/.lightning/debug.log'
expose:
- "9735"
ports:
- "${STACKER_CLN_REST_PORT}:3010"
volumes:
- stacker_cln:/home/clightning/.lightning
labels:
ofelia.enabled: "true"
ofelia.job-exec.stacker_cln_channel_cron.schedule: "@every 1m"
ofelia.job-exec.stacker_cln_channel_cron.command: >
su clightning -c bash -c "
if [ $$(lightning-cli --regtest getinfo | jq '.num_active_channels + .num_pending_channels') -ge 3 ]; then
exit 0
else
lightning-cli --regtest connect $LND_PUBKEY@sn_lnd:9735
lightning-cli --regtest fundchannel id=$LND_PUBKEY feerate=1000perkb \\
amount=1000000000 push_msat=500000000000 minconf=0
fi
"
channdler:
image: mcuadros/ofelia:latest
container_name: channdler
@ -427,4 +478,5 @@ volumes:
bitcoin:
sn_lnd:
stacker_lnd:
stacker_cln:
s3:

16
docker/cln/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM polarlightning/clightning:23.08
RUN apt-get update -y \
&& apt-get install -y jq wget \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN wget https://raw.githubusercontent.com/ElementsProject/lightning/v23.08/plugins/clnrest/requirements.txt \
&& pip install -r requirements.txt
# make sure that wallet and identity is persisted across rebuilds.
# server certificates contain stacker_lnd as a custom domain.
# see https://docs.corelightning.org/docs/grpc#generating-custom-certificates-optional
# since CLNRest in CLNv23.08 seems to use client certificates, they also contain stacker_lnd as a custom domain.
# see https://github.com/ElementsProject/lightning/tree/v23.08/plugins/clnrest#configuration
COPY ["./hsm_secret", "./ca-key.pem", "./ca.pem", "./server-key.pem", "./server.pem", "./client-key.pem", "./client.pem", "/home/clightning/.lightning/regtest/"]

5
docker/cln/ca-key.pem Normal file
View File

@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgXhrU/UORcVCRnDbo
KF35C+BM3FIlCvBX0Y5J2J9piE+hRANCAAQY5mh7rpXIMEhjp5SwB2EJT0kMBlwz
SVsn81a1fYUxWgUXUJNWLnLo00PQKq5xCKxXc9fzs/tl1w+oANsTp2/u
-----END PRIVATE KEY-----

10
docker/cln/ca.pem Normal file
View File

@ -0,0 +1,10 @@
-----BEGIN CERTIFICATE-----
MIIBcjCCARigAwIBAgIJANrSvPZ/Y3KEMAoGCCqGSM49BAMCMBYxFDASBgNVBAMM
C2NsbiBSb290IENBMCAXDTc1MDEwMTAwMDAwMFoYDzQwOTYwMTAxMDAwMDAwWjAW
MRQwEgYDVQQDDAtjbG4gUm9vdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IA
BBjmaHuulcgwSGOnlLAHYQlPSQwGXDNJWyfzVrV9hTFaBRdQk1YucujTQ9AqrnEI
rFdz1/Oz+2XXD6gA2xOnb+6jTTBLMBkGA1UdEQQSMBCCA2NsboIJbG9jYWxob3N0
MB0GA1UdDgQWBBSEcmN/9rzS2hR6G7EIgsX+51N0CjAPBgNVHRMBAf8EBTADAQH/
MAoGCCqGSM49BAMCA0gAMEUCIHCePvNSvyiBYawqKgQquw9J2WVyJxn2MIYIqz9S
QL18AiEAh8BVDz8pX7Nsll8sb0bO0RZ49cvqQocCgVabqJuSuik=
-----END CERTIFICATE-----

1
docker/cln/ca.srl Normal file
View File

@ -0,0 +1 @@
70A2D30FE991B24B5A6BF85421BE5EF083665E7E

28
docker/cln/client-key.pem Normal file
View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDeBF5jfabczcaz
VnYFEB9botC3TevcCpyucbLmCPFU3dcXjUdDJFfnQDlbIAm9UqbUwIF0GIRRJ1Il
H5mZ3MxXVGMNHlLuLdfVMfJFk72WczPecUPh5jJea+ujdW4I5Q+kgx/CjVatPHz5
WYgKC+6d10tawYLR47RRZhOgzAdrCD//L3AE+WPcdWicRSJoYDxdmyQiF9X9Vz6X
NQuOExYk3etHzwEf8alnMZbzS7j10//QxaxR/Ujp4tgt+ACwxNLd7TdoYZ8FP4AY
1ijabYxLGUA+Mk0rC6Al36EE78dUg1fTDCh+ee1YAnoYgnT7jrUQ+6pbiwmfjrO0
lHVDJ+CpAgMBAAECggEABFgj35KezaNjKbEWljkb0U6bO7L/zRxK8AVa+od0KB9b
XvGNfSVY9zYrOuyGELWX7nOz33CF0Wh8LJ4fZkKgZvxk5x6lLAC5Tp7vhYR1LNxs
VpJzJN+gbMxz6/6CFYaVeQ2RWLHzpmhCUf/dxVJVc0Dk8xTX8jLngN4/972SwLKJ
krM2bdAh9crNwi4IKiwnNZKIZA9mZRwSBkpvScV0WSE7JdBT/j4JgTRJY8ya3svo
rc7Lb2b/IeUfJDRbGKW5+5ST3FOUpfXtkHKv+Pg0QYAvdnYLbaI/xaGJkerqE9I8
gXXm5kc39N/PE3LCCLrst7mk/xbJAR9BVGylU81gRwKBgQD1rW1qBce0M1VuuI+i
yoDAYDP7qL4KW/1YnQP377mRThCPu8vKt6EqOUa3JuMWk7o2pYSgUnawWMuVkmDl
1szCHojw1YNblRoO5Qodw8duYE81wXAgAZq3NRE1EBffOAMTo61K63MLq2AxepjW
visVx4F/5bKZ/lD2kkiQpQwpUwKBgQDnWHGgHX3sL1gHLdiKvnLYFX+b1DvtQ/zm
jz5vmDZtWtyyGUD4Hul77RGxrbB3Yhbfulvkgp3yCFb3o1T32XYBHYxhGVQ54YfP
JtwYjd5sGNCofEIJpeVBjgdgDeZWF6AKqgQEw+D1/uIE5E2lZT7HeI+0HL6LIWIo
gj6W+RCCkwKBgHLqd1Zzc7FPnbOXsuAjtsvFdCtQB+ySkNOlRljwEi3shQSmhDHD
aSh1+CTtlKVX3m93Rq0zRX9BWaESAi8gJVDbtZRpWvM4sCKtcejwTdXMSODNJaRi
+7qcoPrgFzp7Wb0S/5kevwaDWBBs1xcDhuW+F0365GrxsW9Uh4rZGPIvAoGAewYi
bnYgd4/5rN+pbqa2ZciQ8qobMCJeg7EbD7cPAno2MJOTZB70JM2+AhGObP4BkfoF
UfBP09yxesEltyOySAeRljUlAB653OQaWQhghnVvyJlDeOP6lTDVJTRfD9tCZUli
F7Kel9JyGQ3baJ/9kY/AQ5Shk1UuYMJaTGioafcCgYEA4DgJWHRbih/KGq+KxCmI
ebFabuGr/XRKaQ5NlnUtPnSOPnfFxSKU+7egKmM4fA4i+wiKwWrsfxIXsgkDGKDc
6T0s9Zf6uGwS+be3g494WoaPKuiwAuutkkjhWQtXAMLsoLl8PAyZsyet4bsyTmPv
9BnJIn6TN2lFZl6yNePe5S0=
-----END PRIVATE KEY-----

17
docker/cln/client.csr Normal file
View File

@ -0,0 +1,17 @@
-----BEGIN CERTIFICATE REQUEST-----
MIICoDCCAYgCAQAwWzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx
ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEUMBIGA1UEAwwLc3Rh
Y2tlcl9jbG4wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDeBF5jfabc
zcazVnYFEB9botC3TevcCpyucbLmCPFU3dcXjUdDJFfnQDlbIAm9UqbUwIF0GIRR
J1IlH5mZ3MxXVGMNHlLuLdfVMfJFk72WczPecUPh5jJea+ujdW4I5Q+kgx/CjVat
PHz5WYgKC+6d10tawYLR47RRZhOgzAdrCD//L3AE+WPcdWicRSJoYDxdmyQiF9X9
Vz6XNQuOExYk3etHzwEf8alnMZbzS7j10//QxaxR/Ujp4tgt+ACwxNLd7TdoYZ8F
P4AY1ijabYxLGUA+Mk0rC6Al36EE78dUg1fTDCh+ee1YAnoYgnT7jrUQ+6pbiwmf
jrO0lHVDJ+CpAgMBAAGgADANBgkqhkiG9w0BAQsFAAOCAQEAs3mdbEeLipHKeGZO
BbZ9vz7Ye0hRMX3q4liSBwGbHm/ByUqtzcXKf+R2T0+5dfc2PJNd7uRxWlRPSDdV
ebHm5ctZaDIGUS2XQrj67KoRRtZTLM4IGM2g5ppbgnm9NM6NWYpO9t2hKupIqNa+
ATYNOzxLHZQJRTvxhC0kpy196huw/vs4d7TVPF7SxJVsXPmjt1OGso52HL0HjDsD
BcNW2Qtd9WcrEwM8HmydBJuSru5gX7HHgKMHUmPtyvFXdTjiiqFnU1apTmF8ptY5
tvNtz8pGmb3p0lvkyAUaEurtzNChwIdU5rvkkZGC8sphpMECPEmMgpavkkZK3+EJ
aUwdqQ==
-----END CERTIFICATE REQUEST-----

16
docker/cln/client.pem Normal file
View File

@ -0,0 +1,16 @@
-----BEGIN CERTIFICATE-----
MIICfzCCAiagAwIBAgIUcKLTD+mRsktaa/hUIb5e8INmXn4wCgYIKoZIzj0EAwIw
FjEUMBIGA1UEAwwLY2xuIFJvb3QgQ0EwHhcNMjQwNDExMTU1ODQ2WhcNMzQwNDA5
MTU1ODQ2WjBbMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8G
A1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMRQwEgYDVQQDDAtzdGFja2Vy
X2NsbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN4EXmN9ptzNxrNW
dgUQH1ui0LdN69wKnK5xsuYI8VTd1xeNR0MkV+dAOVsgCb1SptTAgXQYhFEnUiUf
mZnczFdUYw0eUu4t19Ux8kWTvZZzM95xQ+HmMl5r66N1bgjlD6SDH8KNVq08fPlZ
iAoL7p3XS1rBgtHjtFFmE6DMB2sIP/8vcAT5Y9x1aJxFImhgPF2bJCIX1f1XPpc1
C44TFiTd60fPAR/xqWcxlvNLuPXT/9DFrFH9SOni2C34ALDE0t3tN2hhnwU/gBjW
KNptjEsZQD4yTSsLoCXfoQTvx1SDV9MMKH557VgCehiCdPuOtRD7qluLCZ+Os7SU
dUMn4KkCAwEAAaNCMEAwHQYDVR0OBBYEFE15SBJ5Z/50fktS1qs9agwhQ40PMB8G
A1UdIwQYMBaAFIRyY3/2vNLaFHobsQiCxf7nU3QKMAoGCCqGSM49BAMCA0cAMEQC
IGSPak0NLkIDa1Dyw/NNWeBf+PxLysd9tCIPmMF7YmN3AiAZcdXrWldVW/9RLRyT
0RU2Uqr/47R+MADhX961ZlfzfQ==
-----END CERTIFICATE-----

1
docker/cln/hsm_secret Normal file
View File

@ -0,0 +1 @@
$ְDMBֶװהמ$«bד†y5ת<35>$E0”<30>°P· §)ּ

28
docker/cln/server-key.pem Normal file
View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGJ+Q2predhnnY
roMhBgQNWt3OvY49M6WC2u+P2HWj3+pjjt6oNaM8emYqRXTh4WlAyi8G/XNkADLv
Zf4HMzBp5kYtCemtzce6Zt1LE45Xd7xdFDca7PLOTFaaNY99AENBbaJf0s5ztJSz
IfBisPfhvDwOhbwJB5KZ0ezaa/RItYcOdkZeggbgoYeDA63L+fHTX5Y/Rt9Aooo9
VDix6y1pGMa53jVxdGvj1bb5hkqPtr5zi6hwIum1YDWWwp7wMkft0pnvrnwMQ9mk
A+Pxd8pMie5I5Ks+RPjYk2W8faEccwgN0DXhOBTnxwmdKxVnq+5nU6neZ2d1pRel
rklppay9AgMBAAECggEATWyyz+POZL9xhoeRdurJ1IoHlssb86/lYL640gSq2pAY
HjRprWHf2TaeCrA+3i9cF9OoElwfpRgqzr2URy3qIca27swrwRxhiOS+XKJUgLqp
H9lROrUQnijXwcNhwF7E6KC0zCorPqx1WZTOP1GUWWBaOvZoJUMPNgj/Oczqkymh
W0w1+8cHAlQLs5756xL7lvwUk6sLUXWEEYHv2cW3Q8n7c1y41GQUuVnwrZJGqXDM
er0TGDGeBjahl7RyC+Nd7aA6W1HW8pELI0lAkkiZkRoSw4urG5RE2al9SqtkbiUi
s8ntp1LUk43/mnWgcJ9dLknNiYogNYOb8EdI4OF+xQKBgQD9BbeReC5cP4L9ZpF9
Bzaf1K2dxhJivkzScNjX6ciMV/U8jppHAz7BSFbO7gPczsdk5zQaqPy/A0uhGKhy
74nB+0kguHegASjzqGWpRh6W/CRWFuiktoulS7eSMUqEiJ8OeKpE2Am3I+ltlOyH
gRWdHiEOmZMlWGcrgY3yELlRewKBgQDIfOClARHERNBhbPKAAaW2NTQekMhJPPSZ
aCY6TiIJ+SSWSEF1ythe7HyoIJgcR10UobXrcIuv1LbgqxwLOmsaE7TU8fOGeQPe
HJrr589o3MWmS5K6TvxVcL40dKYjoVGVBVdahkWmoZ6JALlfKTiOHN62EW/CHOJA
wlSyADvZJwKBgQDc09aIwalEnbHHU3N6+Ya1LDtyzeJSB+CochDvMHz17/Z7KcKA
Y9arfmU1KQp59oaUDC2vbvlYBJpHOWwbE/DZOmVyh0zwetKxBbHkcOxVvi5AbLIS
v7dVRqYqk5aD4XFggfOpLhwcmN0r5KQjB4hDnn4fbe281FEG6YVnVS1IbQKBgQCP
WVKaSEB20Ckab/aX9hWRSUtBy42ZaB8QDPrAV5tY/C3f0jwTx/ybKoYbBGseVRxF
ozZa6DbIetRjoZTEpnlrxMlYNMNF1AMi7dsLb8zKEoiz1XdNBSrAwIMPKJSeBzs4
zP/fdwAYG5kqJj1kwCly20uWbLM23MYdPZWnTCl+owKBgFC6Vjw1fPqp4yi8dgZ4
FYpxniLQFgj3wjQiIZbGlN0d0oghF6TJzhbLtdsTMaZVF8hQuhsMIPRuEYxku9no
McqUqX30q+O98h1yCmfJmTtFJrNWPSeHejNAQP0BWmEMZBYD7gaexwDXWvcyQh9f
Y9gQQi/itHYOEBy6L+OC3ETD
-----END PRIVATE KEY-----

17
docker/cln/server.csr Normal file
View File

@ -0,0 +1,17 @@
-----BEGIN CERTIFICATE REQUEST-----
MIICoDCCAYgCAQAwWzELMAkGA1UEBhMCVVMxEzARBgNVBAgMClNvbWUtU3RhdGUx
ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEUMBIGA1UEAwwLc3Rh
Y2tlcl9jbG4wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDGJ+Q2pred
hnnYroMhBgQNWt3OvY49M6WC2u+P2HWj3+pjjt6oNaM8emYqRXTh4WlAyi8G/XNk
ADLvZf4HMzBp5kYtCemtzce6Zt1LE45Xd7xdFDca7PLOTFaaNY99AENBbaJf0s5z
tJSzIfBisPfhvDwOhbwJB5KZ0ezaa/RItYcOdkZeggbgoYeDA63L+fHTX5Y/Rt9A
ooo9VDix6y1pGMa53jVxdGvj1bb5hkqPtr5zi6hwIum1YDWWwp7wMkft0pnvrnwM
Q9mkA+Pxd8pMie5I5Ks+RPjYk2W8faEccwgN0DXhOBTnxwmdKxVnq+5nU6neZ2d1
pRelrklppay9AgMBAAGgADANBgkqhkiG9w0BAQsFAAOCAQEAOoXArh2qS9wxSEKx
JqgTCSxrocuY5xdpeNCoReIQazT+bG332THdiy8a3HtuNGnCSPqrKffpv/G95oSu
eGGxx+n/3sObgzliOk9kDA8XOebuUDb+jM3joLZikGzeFOaMCcwJrhYIyTSmN9Zh
k5rQT6ufRA7LCL5FS4sYG1sly/fYlBqp2kLs2piEgvWdjh2cnfJ2UnyIk/a6sUJI
x7zfmzFAeU3M+fR1Q3//4ISe4MAPDzn/rV8bpcl5iRqagyfbXHIeLfX68mCG3ABf
SKnzUBnfB3g1GjUXIMFzGk23VuKRd6dBHoIMNknkDCM4r4thUtWH7oywWDiYBMX7
oKrKwg==
-----END CERTIFICATE REQUEST-----

16
docker/cln/server.pem Normal file
View File

@ -0,0 +1,16 @@
-----BEGIN CERTIFICATE-----
MIICfzCCAiagAwIBAgIUcKLTD+mRsktaa/hUIb5e8INmXn0wCgYIKoZIzj0EAwIw
FjEUMBIGA1UEAwwLY2xuIFJvb3QgQ0EwHhcNMjQwNDEwMDk1OTA1WhcNMjUwNDEw
MDk1OTA1WjBbMQswCQYDVQQGEwJVUzETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8G
A1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMRQwEgYDVQQDDAtzdGFja2Vy
X2NsbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMYn5Damt52Gediu
gyEGBA1a3c69jj0zpYLa74/YdaPf6mOO3qg1ozx6ZipFdOHhaUDKLwb9c2QAMu9l
/gczMGnmRi0J6a3Nx7pm3UsTjld3vF0UNxrs8s5MVpo1j30AQ0Ftol/SznO0lLMh
8GKw9+G8PA6FvAkHkpnR7Npr9Ei1hw52Rl6CBuChh4MDrcv58dNflj9G30Ciij1U
OLHrLWkYxrneNXF0a+PVtvmGSo+2vnOLqHAi6bVgNZbCnvAyR+3Sme+ufAxD2aQD
4/F3ykyJ7kjkqz5E+NiTZbx9oRxzCA3QNeE4FOfHCZ0rFWer7mdTqd5nZ3WlF6Wu
SWmlrL0CAwEAAaNCMEAwHQYDVR0OBBYEFF4+yBxv8b+F7Jwr1dQbstMRYuO9MB8G
A1UdIwQYMBaAFIRyY3/2vNLaFHobsQiCxf7nU3QKMAoGCCqGSM49BAMCA0cAMEQC
IHDwzjxMRMztT4mN7tRMAHQsMCMbdIeKzDr7g0so19X/AiBZV4kvDbQSbVoJ2UIA
gZiiN5TD+ucMjU4wLzdiBRAFqA==
-----END CERTIFICATE-----

View File

@ -89,6 +89,13 @@ mutation upsertWalletLND($id: ID, $socket: String!, $macaroon: String!, $cert: S
}
`
export const UPSERT_WALLET_CLN =
gql`
mutation upsertWalletCLN($id: ID, $socket: String!, $rune: String!, $cert: String, $settings: AutowithdrawSettings!) {
upsertWalletCLN(id: $id, socket: $socket, rune: $rune, cert: $cert, settings: $settings)
}
`
export const REMOVE_WALLET =
gql`
mutation removeWallet($id: ID!) {
@ -113,6 +120,11 @@ export const WALLET = gql`
macaroon
cert
}
... on WalletCLN {
socket
rune
cert
}
}
}
}
@ -135,6 +147,11 @@ export const WALLET_BY_TYPE = gql`
macaroon
cert
}
... on WalletCLN {
socket
rune
cert
}
}
}
}

161
lib/cln.js Normal file
View File

@ -0,0 +1,161 @@
import fetch from 'node-fetch'
import https from 'https'
export const createInvoice = async ({ socket, rune, cert, label, description, msats, expiry }) => {
const agent = cert ? new https.Agent({ ca: Buffer.from(cert, 'base64') }) : undefined
const url = 'https://' + socket + '/v1/invoice'
const randomId = Math.floor(Math.random() * 1000)
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Rune: rune,
// can be any node id, only required for CLN v23.08 and below
// see https://docs.corelightning.org/docs/rest#server
nodeId: '02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490'
},
agent,
body: JSON.stringify({
// why does CLN require a unique label?
label: description ? `${description} ${randomId}` : randomId,
description,
amount_msat: msats,
expiry
})
})
const inv = await res.json()
if (inv.error) {
throw new Error(inv.error.message)
}
return inv
}
// https://github.com/clams-tech/rune-decoder/blob/57c2e76d1ef9ab7336f565b99de300da1c7b67ce/src/index.ts
export const decodeRune = (rune) => {
const runeBinary = Base64Binary.decode(rune)
const hashBinary = runeBinary.slice(0, 32)
const hash = binaryHashToHex(hashBinary)
const restBinary = runeBinary.slice(32)
const [uniqueId, ...restrictionStrings] = new TextDecoder().decode(restBinary).split('&')
const id = uniqueId.split('=')[1]
// invalid rune checks
if (!id) return null
if (restrictionStrings.some(invalidAscii)) return null
const restrictions = restrictionStrings.map((restriction) => {
const alternatives = restriction.split('|')
const summary = alternatives.reduce((str, alternative) => {
const [operator] = alternative.match(runeOperatorRegex) || []
if (!operator) return str
const [name, value] = alternative.split(operator)
return `${str ? `${str} OR ` : ''}${name} ${operatorToDescription(operator)} ${value}`
}, '')
return {
alternatives,
summary
}
})
return {
id,
hash,
restrictions
}
}
const Base64Binary = {
_keyStr: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=',
removePaddingChars: function (input) {
const lkey = this._keyStr.indexOf(input.charAt(input.length - 1))
if (lkey === 64) {
return input.substring(0, input.length - 1)
}
return input
},
decode: function (input) {
// get last chars to see if are valid
input = this.removePaddingChars(input)
input = this.removePaddingChars(input)
const bytes = parseInt(((input.length / 4) * 3).toString(), 10)
let chr1, chr2, chr3
let enc1, enc2, enc3, enc4
let i = 0
let j = 0
const uarray = new Uint8Array(bytes)
for (i = 0; i < bytes; i += 3) {
// get the 3 octects in 4 ascii chars
enc1 = this._keyStr.indexOf(input.charAt(j++))
enc2 = this._keyStr.indexOf(input.charAt(j++))
enc3 = this._keyStr.indexOf(input.charAt(j++))
enc4 = this._keyStr.indexOf(input.charAt(j++))
chr1 = (enc1 << 2) | (enc2 >> 4)
chr2 = ((enc2 & 15) << 4) | (enc3 >> 2)
chr3 = ((enc3 & 3) << 6) | enc4
uarray[i] = chr1
if (enc3 !== 64) uarray[i + 1] = chr2
if (enc4 !== 64) uarray[i + 2] = chr3
}
return uarray
}
}
function i2hex (i) {
return ('0' + i.toString(16)).slice(-2)
}
const binaryHashToHex = (hash) => {
return hash.reduce(function (memo, i) {
return memo + i2hex(i)
}, '')
}
const runeOperatorRegex = /[=^$/~<>{}#!]/g
const operatorToDescription = (operator) => {
switch (operator) {
case '=':
return 'is equal to'
case '^':
return 'starts with'
case '$':
return 'ends with'
case '/':
return 'is not equal to'
case '~':
return 'contains'
case '<':
return 'is less than'
case '>':
return 'is greater than'
case '{':
return 'sorts before'
case '}':
return 'sorts after'
case '#':
return 'comment'
case '!':
return 'is missing'
default:
return ''
}
}
const invalidAscii = (str) => !![...str].some((char) => char.charCodeAt(0) > 127)

View File

@ -6,12 +6,13 @@ import {
} from './constants'
import { SUPPORTED_CURRENCIES } from './currency'
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
import { msatsToSats, numWithUnits, abbrNum, ensureB64 } from './format'
import { msatsToSats, numWithUnits, abbrNum, ensureB64, B64_URL_REGEX } from './format'
import * as usersFragments from '@/fragments/users'
import * as subsFragments from '@/fragments/subs'
import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon'
import { parseNwcUrl } from './url'
import { datePivot } from './time'
import { decodeRune } from '@/lib/cln'
const { SUB } = subsFragments
const { NAME_QUERY } = usersFragments
@ -327,6 +328,32 @@ export function LNDAutowithdrawSchema ({ me } = {}) {
})
}
export function CLNAutowithdrawSchema ({ me } = {}) {
return object({
socket: string().socket().required('required'),
rune: string().matches(B64_URL_REGEX, { message: 'invalid rune' }).required('required')
.test({
name: 'rune',
test: (v, context) => {
const decoded = decodeRune(v)
if (!decoded) return context.createError({ message: 'invalid rune' })
if (decoded.restrictions.length === 0) {
return context.createError({ message: 'rune must be restricted to method=invoice' })
}
if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) {
return context.createError({ message: 'rune must be restricted to method=invoice only' })
}
if (decoded.restrictions[0].alternatives[0] !== 'method=invoice') {
return context.createError({ message: 'rune must be restricted to method=invoice only' })
}
return true
}
}),
cert: hexOrBase64Validator,
...autowithdrawSchemaMembers({ me })
})
}
export function autowithdrawSchemaMembers ({ me } = {}) {
return {
priority: boolean(),

56
package-lock.json generated
View File

@ -51,6 +51,7 @@
"next-auth": "^4.23.2",
"next-plausible": "^3.11.1",
"next-seo": "^6.1.0",
"node-fetch": "^2.6.1",
"node-s3-url-encode": "^0.0.4",
"nodemailer": "^6.9.6",
"nostr": "^0.2.8",
@ -371,6 +372,25 @@
"node": ">=12"
}
},
"node_modules/@apollo/server/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/@apollo/server/node_modules/uuid": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
@ -7385,6 +7405,25 @@
"node-fetch": "^2.6.12"
}
},
"node_modules/cross-fetch/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -14699,22 +14738,11 @@
"integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA=="
},
"node_modules/node-fetch": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz",
"integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==",
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-gyp-build": {

View File

@ -56,6 +56,7 @@
"next-auth": "^4.23.2",
"next-plausible": "^3.11.1",
"next-seo": "^6.1.0",
"node-fetch": "^2.6.1",
"node-s3-url-encode": "^0.0.4",
"nodemailer": "^6.9.6",
"nostr": "^0.2.8",

View File

@ -130,7 +130,7 @@ export default function Rewards ({ ssrData }) {
</h4>
</Link>
<Row className='pb-3'>
<Col>
<Col lg={leaderboard?.users && 5}>
<div
className='d-flex flex-column sticky-lg-top py-5'
>

View File

@ -0,0 +1,136 @@
import { getGetServerSideProps } from '@/api/ssrApollo'
import { Form, Input } from '@/components/form'
import { CenterLayout } from '@/components/layout'
import { useMe } from '@/components/me'
import { WalletButtonBar, WalletCard } from '@/components/wallet-card'
import { useApolloClient, useMutation } from '@apollo/client'
import { useToast } from '@/components/toast'
import { CLNAutowithdrawSchema } from '@/lib/validate'
import { useRouter } from 'next/router'
import { AutowithdrawSettings, autowithdrawInitial } from '@/components/autowithdraw-shared'
import { REMOVE_WALLET, UPSERT_WALLET_CLN, WALLET_BY_TYPE } from '@/fragments/wallet'
import WalletLogs from '@/components/wallet-logs'
import Info from '@/components/info'
import Text from '@/components/text'
const variables = { type: 'CLN' }
export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true })
export default function CLN ({ ssrData }) {
const me = useMe()
const toaster = useToast()
const router = useRouter()
const client = useApolloClient()
const [upsertWalletCLN] = useMutation(UPSERT_WALLET_CLN, {
refetchQueries: ['WalletLogs'],
onError: (err) => {
client.refetchQueries({ include: ['WalletLogs'] })
throw err
}
})
const [removeWallet] = useMutation(REMOVE_WALLET, {
refetchQueries: ['WalletLogs'],
onError: (err) => {
client.refetchQueries({ include: ['WalletLogs'] })
throw err
}
})
const { walletByType: wallet } = ssrData || {}
return (
<CenterLayout>
<h2 className='pb-2'>CLN</h2>
<h6 className='text-muted text-center'>autowithdraw to your Core Lightning node via <a href='https://docs.corelightning.org/docs/rest' target='_blank' noreferrer rel='noreferrer'>CLNRest</a></h6>
<Form
initial={{
socket: wallet?.wallet?.socket || '',
rune: wallet?.wallet?.rune || '',
cert: wallet?.wallet?.cert || '',
...autowithdrawInitial({ me, priority: wallet?.priority })
}}
schema={CLNAutowithdrawSchema({ me })}
onSubmit={async ({ socket, rune, cert, ...settings }) => {
try {
await upsertWalletCLN({
variables: {
id: wallet?.id,
socket,
rune,
cert,
settings: {
...settings,
autoWithdrawThreshold: Number(settings.autoWithdrawThreshold),
autoWithdrawMaxFeePercent: Number(settings.autoWithdrawMaxFeePercent)
}
}
})
toaster.success('saved settings')
router.push('/settings/wallets')
} catch (err) {
console.error(err)
}
}}
>
<Input
label='rest host and port'
name='socket'
hint='tor or clearnet'
placeholder='55.5.555.55:3010'
clear
required
autoFocus
/>
<Input
label={
<div className='d-flex align-items-center'>invoice only rune
<Info>
<Text>
{'We only accept runes that *only* allow `method=invoice`.\nRun this to generate one:\n\n```lightning-cli createrune restrictions=\'["method=invoice"]\'```'}
</Text>
</Info>
</div>
}
name='rune'
clear
hint='must be restricted to method=invoice'
placeholder='S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ=='
required
/>
<Input
label={<>cert <small className='text-muted ms-2'>optional if from <a href='https://en.wikipedia.org/wiki/Certificate_authority' target='_blank' rel='noreferrer'>CA</a> (e.g. voltage)</small></>}
name='cert'
clear
hint='hex or base64 encoded'
placeholder='LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K'
/>
<AutowithdrawSettings />
<WalletButtonBar
enabled={!!wallet} onDelete={async () => {
try {
await removeWallet({ variables: { id: wallet?.id } })
toaster.success('saved settings')
router.push('/settings/wallets')
} catch (err) {
console.error(err)
}
}}
/>
</Form>
<div className='mt-3 w-100'>
<WalletLogs wallet='cln' embedded />
</div>
</CenterLayout>
)
}
export function CLNCard ({ wallet }) {
return (
<WalletCard
title='CLN'
badges={['receive only', 'non-custodial']}
provider='cln'
enabled={wallet !== undefined || undefined}
/>
)
}

View File

@ -6,6 +6,7 @@ import { LightningAddressWalletCard } from './lightning-address'
import { LNbitsCard } from './lnbits'
import { NWCCard } from './nwc'
import { LNDCard } from './lnd'
import { CLNCard } from './cln'
import { WALLETS } from '@/fragments/wallet'
import { useQuery } from '@apollo/client'
import PageLoading from '@/components/page-loading'
@ -19,6 +20,7 @@ export default function Wallet ({ ssrData }) {
const { wallets } = data || ssrData
const lnd = wallets.find(w => w.type === 'LND')
const lnaddr = wallets.find(w => w.type === 'LIGHTNING_ADDRESS')
const cln = wallets.find(w => w.type === 'CLN')
return (
<Layout>
@ -28,6 +30,7 @@ export default function Wallet ({ ssrData }) {
<div className={styles.walletGrid}>
<LightningAddressWalletCard wallet={lnaddr} />
<LNDCard wallet={lnd} />
<CLNCard wallet={cln} />
<LNbitsCard />
<NWCCard />
<WalletCard title='coming soon' badges={['probably']} />

View File

@ -0,0 +1,25 @@
-- AlterEnum
ALTER TYPE "WalletType" ADD VALUE 'CLN';
-- CreateTable
CREATE TABLE "WalletCLN" (
"id" SERIAL NOT NULL,
"walletId" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"socket" TEXT NOT NULL,
"rune" TEXT NOT NULL,
"cert" TEXT,
CONSTRAINT "WalletCLN_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WalletCLN_walletId_key" ON "WalletCLN"("walletId");
-- AddForeignKey
ALTER TABLE "WalletCLN" ADD CONSTRAINT "WalletCLN_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TRIGGER wallet_cln_as_jsonb
AFTER INSERT OR UPDATE ON "WalletCLN"
FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb();

View File

@ -133,6 +133,7 @@ model User {
enum WalletType {
LIGHTNING_ADDRESS
LND
CLN
}
model Wallet {
@ -154,6 +155,7 @@ model Wallet {
wallet Json? @db.JsonB
walletLightningAddress WalletLightningAddress?
walletLND WalletLND?
walletCLN WalletCLN?
@@index([userId])
}
@ -190,6 +192,17 @@ model WalletLND {
cert String?
}
model WalletCLN {
id Int @id @default(autoincrement())
walletId Int @unique
wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
socket String
rune String
cert String?
}
model Mute {
muterId Int
mutedId Int

72
sndev
View File

@ -10,7 +10,7 @@ docker__compose() {
fi
if [ -z "$COMPOSE_PROFILES" ]; then
COMPOSE_PROFILES="images,search,payments,email"
COMPOSE_PROFILES="images,search,payments,email,capture"
fi
CURRENT_UID=$(id -u) CURRENT_GID=$(id -g) COMPOSE_PROFILES=$COMPOSE_PROFILES command docker compose --env-file .env.development "$@"
@ -23,7 +23,7 @@ docker__exec() {
exit 0
fi
command docker exec -i "$@"
DOCKER_CLI_HINTS=false command docker exec -i "$@"
}
docker__sn_lnd() {
@ -48,6 +48,17 @@ docker__stacker_lnd() {
docker__exec $t -u lnd stacker_lnd lncli "$@"
}
docker__stacker_cln() {
t=$1
if [ "$t" = "-t" ]; then
shift
else
t=""
fi
docker__exec $t -u clightning stacker_cln lightning-cli --regtest "$@"
}
sndev__start() {
shift
@ -207,6 +218,35 @@ OPTIONS"
docker__stacker_lnd payinvoice -h | awk '/OPTIONS:/{y=1;next}y' | awk '!/^[\t ]+--pay_req value/'
}
sndev__cln_fund() {
shift
docker__stacker_cln -t pay "$@"
}
sndev__help_cln_fund() {
help="
pay a bolt11 for funding with CLN
USAGE
$ sndev cln_fund <bolt11>"
echo "$help"
}
sndev__cln_withdraw() {
shift
label=$(date +%s)
docker__stacker_cln -t invoice "$1" "$label" sndev | jq -r '.bolt11'
}
sndev__help_cln_withdraw() {
help="
create a bolt11 for withdrawal with CLN
USAGE
$ sndev cln_withdraw <amount msats>"
echo "$help"
}
sndev__withdraw() {
shift
docker__stacker_lnd addinvoice --amt "$@" | jq -r '.payment_request'
@ -287,24 +327,33 @@ sndev__help_compose() {
docker__compose --help
}
sndev__sn_lncli() {
sndev__sn_lndcli() {
shift
docker__sn_lnd -t "$@"
}
sndev__help_sn_lncli() {
sndev__help_sn_lndcli() {
docker__sn_lnd --help
}
sndev__stacker_lncli() {
sndev__stacker_lndcli() {
shift
docker__stacker_lnd -t "$@"
}
sndev__help_stacker_lncli() {
sndev__help_stacker_lndcli() {
docker__stacker_lnd --help
}
sndev__stacker_clncli() {
shift
docker__stacker_cln -t "$@"
}
sndev__help_stacker_clncli() {
docker__stacker_cln help
}
__sndev__pr_track() {
json=$(curl -fsSH "Accept: application/vnd.github.v3+json" "https://api.github.com/repos/stackernews/stacker.news/pulls/$1")
case $(git config --get remote.origin.url) in
@ -441,6 +490,10 @@ COMMANDS
fund pay a bolt11 for funding
withdraw create a bolt11 for withdrawal
cln:
cln_fund pay a bolt11 for funding with CLN
cln_withdraw create a bolt11 for withdrawal with CLN
db:
psql open psql on db
prisma run prisma commands
@ -450,9 +503,10 @@ COMMANDS
lint run linters
other:
compose docker compose passthrough
sn_lncli lncli passthrough on sn_lnd
stacker_lncli lncli passthrough on stacker_lnd
compose docker compose passthrough
sn_lndcli lncli passthrough on sn_lnd
stacker_lndcli lncli passthrough on stacker_lnd
stacker_clncli lightning-cli passthrough on stacker_cln
"
echo "$help"
}

View File

@ -130,6 +130,8 @@ $accordion-button-icon-dark: $accordion-button-icon;
$accordion-button-active-icon-dark: $accordion-button-icon;
$zindex-sticky: 900;
:root, [data-bs-theme=light] {
--theme-navLink: rgba(0, 0, 0, 0.55);
--theme-navLinkFocus: rgba(0, 0, 0, 0.7);

View File

@ -2,6 +2,7 @@ import { authenticatedLndGrpc, createInvoice } from 'ln-service'
import { msatsToSats, numWithUnits, satsToMsats } from '@/lib/format'
import { datePivot } from '@/lib/time'
import { createWithdrawal, sendToLnAddr, addWalletLog } from '@/api/resolvers/wallet'
import { createInvoice as createInvoiceCLN } from '@/lib/cln'
export async function autoWithdraw ({ data: { id }, models, lnd }) {
const user = await models.user.findUnique({ where: { id } })
@ -55,6 +56,15 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
level: 'SUCCESS',
message
}, { me: user, models })
} else if (wallet.type === 'CLN') {
await autowithdrawCLN(
{ amount, maxFee },
{ models, me: user, lnd })
await addWalletLog({
wallet: 'walletCLN',
level: 'SUCCESS',
message
}, { me: user, models })
} else if (wallet.type === 'LIGHTNING_ADDRESS') {
await autowithdrawLNAddr(
{ amount, maxFee },
@ -72,7 +82,9 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
// LND errors are in this shape: [code, type, { err: { code, details, metadata } }]
const details = error[2]?.err?.details || error.message || error.toString?.()
await addWalletLog({
wallet: wallet.type === 'LND' ? 'walletLND' : 'walletLightningAddress',
wallet: wallet.type === 'LND'
? 'walletLND'
: wallet.type === 'CLN' ? 'walletCLN' : 'walletLightningAddress',
level: 'ERROR',
message: 'autowithdrawal failed: ' + details
})
@ -142,3 +154,36 @@ async function autowithdrawLND ({ amount, maxFee }, { me, models, lnd }) {
return await createWithdrawal(null, { invoice: invoice.request, maxFee }, { me, models, lnd, autoWithdraw: true })
}
async function autowithdrawCLN ({ amount, maxFee }, { me, models, lnd }) {
if (!me) {
throw new Error('me not specified')
}
const wallet = await models.wallet.findFirst({
where: {
userId: me.id,
type: 'CLN'
},
include: {
walletCLN: true
}
})
if (!wallet || !wallet.walletCLN) {
throw new Error('no cln wallet found')
}
const { walletCLN: { cert, rune, socket } } = wallet
const inv = await createInvoiceCLN({
socket,
rune,
cert,
description: me.hideInvoiceDesc ? undefined : 'autowithdraw to CLN from SN',
msats: amount + 'sat',
expiry: 360
})
return await createWithdrawal(null, { invoice: inv.bolt11, maxFee }, { me, models, lnd, autoWithdraw: true })
}