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>
This commit is contained in:
ekzyis 2024-04-15 00:34:21 +02:00 committed by GitHub
parent 3aee93ee16
commit 9f4d5e13aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 795 additions and 30 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

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

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

@ -253,14 +253,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 +384,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 +394,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 +479,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

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

68
sndev
View File

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

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