From 9f4d5e13aa2700d21bef0ac09ac91e134f88ce34 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 15 Apr 2024 00:34:21 +0200 Subject: [PATCH] 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 --- .env.sample | 6 + api/resolvers/wallet.js | 37 +++- api/typeDefs/wallet.js | 9 +- components/log-message.js | 2 +- components/logger.js | 1 + docker-compose.yml | 59 ++++++- docker/cln/Dockerfile | 16 ++ docker/cln/ca-key.pem | 5 + docker/cln/ca.pem | 10 ++ docker/cln/ca.srl | 1 + docker/cln/client-key.pem | 28 +++ docker/cln/client.csr | 17 ++ docker/cln/client.pem | 16 ++ docker/cln/hsm_secret | 1 + docker/cln/server-key.pem | 28 +++ docker/cln/server.csr | 17 ++ docker/cln/server.pem | 16 ++ fragments/wallet.js | 17 ++ lib/cln.js | 161 ++++++++++++++++++ lib/validate.js | 29 +++- package-lock.json | 56 ++++-- package.json | 1 + pages/settings/wallets/cln.js | 136 +++++++++++++++ pages/settings/wallets/index.js | 3 + .../20240410060146_wallet_cln/migration.sql | 25 +++ prisma/schema.prisma | 13 ++ sndev | 68 +++++++- worker/autowithdraw.js | 47 ++++- 28 files changed, 795 insertions(+), 30 deletions(-) create mode 100644 docker/cln/Dockerfile create mode 100644 docker/cln/ca-key.pem create mode 100644 docker/cln/ca.pem create mode 100644 docker/cln/ca.srl create mode 100644 docker/cln/client-key.pem create mode 100644 docker/cln/client.csr create mode 100644 docker/cln/client.pem create mode 100644 docker/cln/hsm_secret create mode 100644 docker/cln/server-key.pem create mode 100644 docker/cln/server.csr create mode 100644 docker/cln/server.pem create mode 100644 lib/cln.js create mode 100644 pages/settings/wallets/cln.js create mode 100644 prisma/migrations/20240410060146_wallet_cln/migration.sql diff --git a/.env.sample b/.env.sample index c900a32a..d4b83bcc 100644 --- a/.env.sample +++ b/.env.sample @@ -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 diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index ccfbe8f8..9abc6af0 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -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' } diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 50357e7b..e16897c0 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -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! diff --git a/components/log-message.js b/components/log-message.js index c94be1a7..3a78a230 100644 --- a/components/log-message.js +++ b/components/log-message.js @@ -8,7 +8,7 @@ export default function LogMessage ({ wallet, level, message, ts }) { {timeSince(new Date(ts))} [{wallet}] - {level} + {level === 'success' ? 'ok' : level} {message} ) diff --git a/components/logger.js b/components/logger.js index da58f7b3..8b34f90f 100644 --- a/components/logger.js +++ b/components/logger.js @@ -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 } diff --git a/docker-compose.yml b/docker-compose.yml index 46531251..9763a2e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docker/cln/Dockerfile b/docker/cln/Dockerfile new file mode 100644 index 00000000..3fb6e93f --- /dev/null +++ b/docker/cln/Dockerfile @@ -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/"] \ No newline at end of file diff --git a/docker/cln/ca-key.pem b/docker/cln/ca-key.pem new file mode 100644 index 00000000..ac13c7d1 --- /dev/null +++ b/docker/cln/ca-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgXhrU/UORcVCRnDbo +KF35C+BM3FIlCvBX0Y5J2J9piE+hRANCAAQY5mh7rpXIMEhjp5SwB2EJT0kMBlwz +SVsn81a1fYUxWgUXUJNWLnLo00PQKq5xCKxXc9fzs/tl1w+oANsTp2/u +-----END PRIVATE KEY----- diff --git a/docker/cln/ca.pem b/docker/cln/ca.pem new file mode 100644 index 00000000..9b43b805 --- /dev/null +++ b/docker/cln/ca.pem @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBcjCCARigAwIBAgIJANrSvPZ/Y3KEMAoGCCqGSM49BAMCMBYxFDASBgNVBAMM +C2NsbiBSb290IENBMCAXDTc1MDEwMTAwMDAwMFoYDzQwOTYwMTAxMDAwMDAwWjAW +MRQwEgYDVQQDDAtjbG4gUm9vdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IA +BBjmaHuulcgwSGOnlLAHYQlPSQwGXDNJWyfzVrV9hTFaBRdQk1YucujTQ9AqrnEI +rFdz1/Oz+2XXD6gA2xOnb+6jTTBLMBkGA1UdEQQSMBCCA2NsboIJbG9jYWxob3N0 +MB0GA1UdDgQWBBSEcmN/9rzS2hR6G7EIgsX+51N0CjAPBgNVHRMBAf8EBTADAQH/ +MAoGCCqGSM49BAMCA0gAMEUCIHCePvNSvyiBYawqKgQquw9J2WVyJxn2MIYIqz9S +QL18AiEAh8BVDz8pX7Nsll8sb0bO0RZ49cvqQocCgVabqJuSuik= +-----END CERTIFICATE----- diff --git a/docker/cln/ca.srl b/docker/cln/ca.srl new file mode 100644 index 00000000..eed940cf --- /dev/null +++ b/docker/cln/ca.srl @@ -0,0 +1 @@ +70A2D30FE991B24B5A6BF85421BE5EF083665E7E diff --git a/docker/cln/client-key.pem b/docker/cln/client-key.pem new file mode 100644 index 00000000..4e9150e8 --- /dev/null +++ b/docker/cln/client-key.pem @@ -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----- diff --git a/docker/cln/client.csr b/docker/cln/client.csr new file mode 100644 index 00000000..1db40087 --- /dev/null +++ b/docker/cln/client.csr @@ -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----- diff --git a/docker/cln/client.pem b/docker/cln/client.pem new file mode 100644 index 00000000..c29be2ea --- /dev/null +++ b/docker/cln/client.pem @@ -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----- diff --git a/docker/cln/hsm_secret b/docker/cln/hsm_secret new file mode 100644 index 00000000..eee2469a --- /dev/null +++ b/docker/cln/hsm_secret @@ -0,0 +1 @@ +$ÀDMBÆÔäî$«bã†y5úÞ$E0”Þ°P· §)Ì \ No newline at end of file diff --git a/docker/cln/server-key.pem b/docker/cln/server-key.pem new file mode 100644 index 00000000..a5663d1f --- /dev/null +++ b/docker/cln/server-key.pem @@ -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----- diff --git a/docker/cln/server.csr b/docker/cln/server.csr new file mode 100644 index 00000000..1173be1f --- /dev/null +++ b/docker/cln/server.csr @@ -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----- diff --git a/docker/cln/server.pem b/docker/cln/server.pem new file mode 100644 index 00000000..087800f6 --- /dev/null +++ b/docker/cln/server.pem @@ -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----- diff --git a/fragments/wallet.js b/fragments/wallet.js index 85abd619..bcbb658e 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -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 + } } } } diff --git a/lib/cln.js b/lib/cln.js new file mode 100644 index 00000000..034457f8 --- /dev/null +++ b/lib/cln.js @@ -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) diff --git a/lib/validate.js b/lib/validate.js index 446b1731..51ffa0e3 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -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(), diff --git a/package-lock.json b/package-lock.json index a25d046d..a32041d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index fe7b3512..6fe2134e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pages/settings/wallets/cln.js b/pages/settings/wallets/cln.js new file mode 100644 index 00000000..80d36ae0 --- /dev/null +++ b/pages/settings/wallets/cln.js @@ -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 ( + +

CLN

+
autowithdraw to your Core Lightning node via CLNRest
+
{ + 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) + } + }} + > + + invoice only rune + + + {'We only accept runes that *only* allow `method=invoice`.\nRun this to generate one:\n\n```lightning-cli createrune restrictions=\'["method=invoice"]\'```'} + + + + } + name='rune' + clear + hint='must be restricted to method=invoice' + placeholder='S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ==' + required + /> + cert optional if from CA (e.g. voltage)} + name='cert' + clear + hint='hex or base64 encoded' + placeholder='LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K' + /> + + { + try { + await removeWallet({ variables: { id: wallet?.id } }) + toaster.success('saved settings') + router.push('/settings/wallets') + } catch (err) { + console.error(err) + } + }} + /> + +
+ +
+
+ ) +} + +export function CLNCard ({ wallet }) { + return ( + + ) +} diff --git a/pages/settings/wallets/index.js b/pages/settings/wallets/index.js index 088d0dca..077d30b0 100644 --- a/pages/settings/wallets/index.js +++ b/pages/settings/wallets/index.js @@ -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 ( @@ -28,6 +30,7 @@ export default function Wallet ({ ssrData }) {
+ diff --git a/prisma/migrations/20240410060146_wallet_cln/migration.sql b/prisma/migrations/20240410060146_wallet_cln/migration.sql new file mode 100644 index 00000000..207bbe71 --- /dev/null +++ b/prisma/migrations/20240410060146_wallet_cln/migration.sql @@ -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(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7ada75c7..06f99916 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/sndev b/sndev index ff49f838..e5e779fe 100755 --- a/sndev +++ b/sndev @@ -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 " + 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 " + 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" } diff --git a/worker/autowithdraw.js b/worker/autowithdraw.js index 00bcecc4..891de302 100644 --- a/worker/autowithdraw.js +++ b/worker/autowithdraw.js @@ -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 }) +}