├── src ├── utils │ ├── constants.ts │ ├── grpc.ts │ ├── common.ts │ └── lnd-api.ts ├── server.ts ├── db │ ├── user.ts │ ├── db.ts │ ├── withdrawalCode.ts │ └── payment.ts ├── app.ts └── api │ ├── withdraw.ts │ ├── user │ └── manage.ts │ └── pay.ts ├── jestSetup.js ├── .gitignore ├── .prettierrc ├── jest.config.js ├── tests └── app.test.ts ├── mocks └── utils │ ├── grpc.ts │ └── lnd-api.ts ├── scripts ├── create-user.js └── create-withdrawal-code.js ├── config ├── config_TEMPLATE.ts └── interface.ts ├── LICENSE ├── migrations └── 001-initial.sql ├── package.json ├── README.md ├── tsconfig-tests.json ├── tsconfig.json └── proto ├── walletunlocker.proto └── router.proto /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const MSAT = 1000; 2 | -------------------------------------------------------------------------------- /jestSetup.js: -------------------------------------------------------------------------------- 1 | jest.mock("./src/utils/grpc", () => require("./mocks/utils/grpc")); 2 | jest.mock("./src/utils/lnd-api", () => require("./mocks/utils/lnd-api")); 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | yarn-error.log 4 | 5 | dist 6 | config/config.ts 7 | database.db 8 | src/proto.js 9 | src/proto.d.ts 10 | coverage 11 | .nyc_output 12 | .DS_Store 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": false, 6 | "endOfLine": "lf", 7 | "printWidth": 100, 8 | "arrowParens": "always" 9 | } 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | globals: { 5 | "ts-jest": { 6 | tsconfig: "./tsconfig-tests.json", 7 | }, 8 | }, 9 | setupFiles: ["./jestSetup.js"], 10 | }; 11 | -------------------------------------------------------------------------------- /tests/app.test.ts: -------------------------------------------------------------------------------- 1 | import build from "../src/app"; 2 | const app = build(); 3 | 4 | test('requests the "/" route', async (done) => { 5 | const response = await app.inject({ 6 | method: "GET", 7 | url: "/", 8 | }); 9 | expect(response.statusCode).toBe(200); 10 | 11 | done(); 12 | }); 13 | 14 | afterAll(() => { 15 | app.close(); 16 | }); 17 | -------------------------------------------------------------------------------- /mocks/utils/grpc.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@grpc/grpc-js"; 2 | 3 | export const rpcImpl = jest.fn(); 4 | 5 | export const getGrpcClients = () => jest.fn(); 6 | 7 | export const grpcReqSerialize = (args: any) => jest.fn(); 8 | 9 | export const grpcReqDeserialize = (args: any) => jest.fn(); 10 | 11 | export const grpcMakeUnaryRequest = ( 12 | client: Client, 13 | method: string, 14 | argument: Uint8Array, 15 | decoder = (data: Uint8Array) => data as any, 16 | ) => jest.fn(); 17 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import config from "../config/config"; 2 | 3 | import app from "./app"; 4 | 5 | const host = config.serverHost; 6 | const domain = host.split(":")[0]; 7 | const port = Number.parseInt(host.split(":")[1] ?? "8080"); 8 | 9 | (async () => { 10 | const server = await app(); 11 | 12 | server.listen(port, domain, (err, address) => { 13 | if (err) { 14 | console.error(err); 15 | process.exit(1); 16 | } 17 | console.log(`Server listening at ${address}`); 18 | }); 19 | })(); 20 | -------------------------------------------------------------------------------- /scripts/create-user.js: -------------------------------------------------------------------------------- 1 | const getDb = require("../dist/src/db/db").default; 2 | 3 | (async () => { 4 | const db = await getDb(); 5 | 6 | const pubkey = process.argv[2]; 7 | const alias = process.argv[3]; 8 | 9 | if (!pubkey) { 10 | console.log(`USAGE:\n create-user.js pubkey alias`); 11 | process.exit(0); 12 | } 13 | 14 | await db.run("INSERT INTO user (pubkey, alias) VALUES ($pubkey, $alias)", { 15 | $pubkey: pubkey, 16 | $alias: alias, 17 | }); 18 | 19 | console.log("Done"); 20 | })(); 21 | -------------------------------------------------------------------------------- /scripts/create-withdrawal-code.js: -------------------------------------------------------------------------------- 1 | const getDb = require("../dist/src/db/db").default; 2 | 3 | (async () => { 4 | const db = await getDb(); 5 | 6 | const userAlias = process.argv[2]; 7 | const code = process.argv[3]; 8 | 9 | if (!userAlias || !code) { 10 | console.log(`USAGE:\n create-withdrawal-code.js userAlias code`); 11 | process.exit(0); 12 | } 13 | 14 | await db.run("INSERT INTO withdrawalCode (code, userAlias) VALUES ($code, $userAlias)", { 15 | $code: code, 16 | $userAlias: userAlias, 17 | }); 18 | 19 | console.log("Done"); 20 | })(); 21 | -------------------------------------------------------------------------------- /config/config_TEMPLATE.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "./interface"; 2 | 3 | // Please see interface.ts for description about each config 4 | const config: Config = { 5 | env: "prod", 6 | serverHost: "0.0.0.0:8080", 7 | domain: "192.168.1.1:8080", 8 | domainUrl: "http://192.168.1.1:8080", 9 | backend: "lnd", 10 | backendConfigLnd: { 11 | grpcServer: "127.0.0.1:10007", 12 | cert: "~/path/to/lnd/tls.cert", 13 | adminMacaroon: "~/path/to/lnd/admin.macaroon", 14 | }, 15 | singlePaymentForwardWithdrawLimit: 5, 16 | disableCustodial: false, 17 | }; 18 | 19 | export default config; 20 | -------------------------------------------------------------------------------- /src/db/user.ts: -------------------------------------------------------------------------------- 1 | import { Database } from "sqlite"; 2 | 3 | export interface IUserDB { 4 | alias: string; 5 | pubkey: string; 6 | } 7 | 8 | export async function createUser(db: Database, { alias, pubkey }: IUserDB) { 9 | await db.run( 10 | `INSERT INTO user 11 | (alias, pubkey) 12 | VALUES 13 | ($alias, $pubkey) 14 | `, 15 | { 16 | $alias: alias, 17 | $pubkey: pubkey, 18 | }, 19 | ); 20 | } 21 | 22 | export function getUserByPubkey(db: Database, pubkey: string) { 23 | return db.get(`SELECT * FROM user WHERE pubkey = $pubkey`, { 24 | $pubkey: pubkey, 25 | }); 26 | } 27 | 28 | export function getUserByAlias(db: Database, alias: string) { 29 | return db.get(`SELECT * FROM user WHERE alias = $alias`, { 30 | $alias: alias, 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /src/db/db.ts: -------------------------------------------------------------------------------- 1 | import sqlite3 from "sqlite3"; 2 | import { Database, open } from "sqlite"; 3 | import config from "../../config/config"; 4 | 5 | let db: Database | null = null; 6 | 7 | export default async function getDb(forceReopen: boolean = false) { 8 | if (db && !forceReopen) { 9 | return db; 10 | } 11 | 12 | db = await open({ 13 | filename: config.env === "test" ? ":memory:" : "./database.db", 14 | driver: sqlite3.Database, 15 | }); 16 | await db.migrate(); 17 | 18 | if (config.env === "development") { 19 | sqlite3.verbose(); 20 | } 21 | 22 | return db; 23 | } 24 | 25 | export async function beginTransaction(db: Database) { 26 | await db.run("BEGIN TRANSACTION"); 27 | return; 28 | } 29 | 30 | export async function commit(db: Database) { 31 | await db.run("COMMIT"); 32 | return; 33 | } 34 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import fastify, { FastifyServerOptions } from "fastify"; 2 | import fastifyCors from "fastify-cors"; 3 | 4 | import { getInfo } from "./utils/lnd-api"; 5 | import { getGrpcClients } from "./utils/grpc"; 6 | 7 | const { lightning, router } = getGrpcClients(); 8 | 9 | export default async function (options?: FastifyServerOptions) { 10 | const app = fastify(options); 11 | app.register(fastifyCors); 12 | 13 | app.register(require("./api/pay"), { 14 | lightning, 15 | router, 16 | }); 17 | 18 | app.register(require("./api/withdraw"), { 19 | lightning, 20 | router, 21 | }); 22 | 23 | app.register(require("./api/user/manage"), { 24 | lightning, 25 | router, 26 | }); 27 | 28 | app.get("/getInfo", async function () { 29 | return await getInfo(lightning); 30 | }); 31 | 32 | return app; 33 | } 34 | -------------------------------------------------------------------------------- /src/db/withdrawalCode.ts: -------------------------------------------------------------------------------- 1 | import { Database } from "sqlite"; 2 | 3 | export interface IWithdrawalCodeDB { 4 | code: string; 5 | userAlias: string; 6 | } 7 | 8 | export async function createWithdrawalCode(db: Database, { code, userAlias }: IWithdrawalCodeDB) { 9 | await db.run( 10 | `INSERT INTO payment 11 | (code, userAlias) 12 | VALUES 13 | ($code, $userAlias) 14 | `, 15 | { 16 | $code: code, 17 | $userAlias: userAlias, 18 | }, 19 | ); 20 | } 21 | 22 | export function getWithdrawalCode(db: Database, code: string) { 23 | return db.get(`SELECT * FROM withdrawalCode WHERE code = $code`, { 24 | $code: code, 25 | }); 26 | } 27 | 28 | export function getWithdrawalCodes(db: Database, userAlias: string) { 29 | return db.all(`SELECT * FROM withdrawalCode WHERE userAlias = $userAlias`, { 30 | $userAlias: userAlias, 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Hampus Sjöberg 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /config/interface.ts: -------------------------------------------------------------------------------- 1 | export interface Config { 2 | // Current environment 3 | env: "prod" | "development" | "test"; 4 | 5 | // serverHost is the host interface, optionally with port (i.e 127.0.0.1:8080). 6 | serverHost: string; 7 | 8 | // domain is the domain name to be used for Lighting Addresses (i.e satoshi@). 9 | domain: string; 10 | 11 | // domainUrl is the accessible URL (i.e http://site.com). 12 | // This is needed to be able to construct lnurl callbacks. 13 | domainUrl: string; 14 | 15 | // backend is the lightning node that will act as a backend for this server. 16 | backend: "lnd"; 17 | 18 | // Backend config specifically for lnd. 19 | backendConfigLnd?: { 20 | // Address to the gRPC server (i.e 127.0.0.1:10009). 21 | grpcServer: string; 22 | 23 | // Path to tls.cert (i.e ~/.lnd/tls.cert). 24 | cert: string; 25 | 26 | // Path to the admin.macaroon (i.e ~/.lnd/data/chain/bitcoin/mainnet/admin.macaroon). 27 | adminMacaroon: string; 28 | }; 29 | 30 | // The number of single payment withdrawals (in contrast to batch withdrawal) we allow. 31 | // If exceeded, batch withdrawal is enforced. 32 | singlePaymentForwardWithdrawLimit: number; 33 | 34 | // Disable the custodial part of Lightning Box. 35 | // This requires users to be online at the time of the payment request. 36 | // Otherwise the request will immediately fail. 37 | disableCustodial: boolean; 38 | } 39 | -------------------------------------------------------------------------------- /migrations/001-initial.sql: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- 2 | -- Up 3 | -------------------------------------------------------------------------------- 4 | 5 | -- `user` contains the alias, as in @ 6 | -- `pubkey` refers to the node pubkey that the service will allow forwards to 7 | CREATE TABLE user ( 8 | alias TEXT PRIMARY KEY, 9 | pubkey TEXT NOT NULL 10 | ); 11 | CREATE INDEX index_user_pubkey ON user(pubkey); 12 | 13 | -- `userAuthentication` binds a valid LNURL-auth pubkey 14 | -- and will be used to bind adminstration of the user 15 | CREATE TABLE userAuthentication ( 16 | userAlias, 17 | pubkey 18 | ); 19 | 20 | -- `withdrawalCode` are to be used to construct valid LNURL-withdraw endpoints for a user 21 | CREATE TABLE withdrawalCode ( 22 | code TEXT PRIMARY KEY, 23 | userAlias TEXT NOT NULL 24 | ); 25 | CREATE INDEX index_withdrawalCode_userAlias ON withdrawalCode(userAlias); 26 | 27 | -- `payment` keeps tracks of each payment and whether it has been settled and forwarded 28 | CREATE TABLE payment ( 29 | paymentRequest TEXT PRIMARY KEY, 30 | paymentRequestForward TEXT NULL, 31 | userAlias TEXT NOT NULL, 32 | amountSat INTEGER NOT NULL, 33 | settled BOOLEAN NOT NULL, 34 | forwarded BOOLEAN NOT NULL, 35 | comment TEXT, 36 | 37 | CHECK (settled IN (0, 1)), 38 | CHECK (forwarded IN (0, 1)) 39 | ); 40 | CREATE INDEX index_user_user_alias ON payment(userAlias); 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lightning-box", 3 | "scripts": { 4 | "build": "tsc -p tsconfig.json", 5 | "postbuild": "cp src/proto.js dist", 6 | "start": "rm -rf dist && npm run build && node dist/src/server.js", 7 | "watch": "concurrently \"tsc -p tsconfig.json -w\" \"nodemon -w dist dist/server.js\"", 8 | "proto": "pbjs --force-long -t static-module -o src/proto.js proto/lightning.proto proto/router.proto && pbts -o src/proto.d.ts src/proto.js", 9 | "test": "jest tests", 10 | "test:coverage": "jest --coverage --coveragePathIgnorePatterns \"proto\\.js|mocks\" tests" 11 | }, 12 | "version": "1.0.0", 13 | "private": true, 14 | "author": "Hampus Sjöberg", 15 | "license": "MIT", 16 | "dependencies": { 17 | "@grpc/grpc-js": "^1.3.6", 18 | "@grpc/proto-loader": "^0.6.4", 19 | "bech32": "2.0.0", 20 | "config": "^3.3.6", 21 | "date-fns": "^2.23.0", 22 | "fastify": "^3.14.2", 23 | "fastify-cookie": "^5.3.0", 24 | "fastify-cors": "^5.2.0", 25 | "fastify-session": "^5.2.1", 26 | "fastify-static": "^4.2.2", 27 | "fastify-websocket": "^3.2.0", 28 | "grpc": "^1.24.11", 29 | "protobufjs": "^6.11.2", 30 | "qrcode-terminal": "^0.12.0", 31 | "secp256k1": "^4.0.2", 32 | "sql-template-strings": "^2.2.2", 33 | "sqlite": "^4.0.23", 34 | "sqlite3": "5.0.2", 35 | "sqlstring": "^2.3.2", 36 | "typescript": "^4.3.5" 37 | }, 38 | "devDependencies": { 39 | "@types/config": "^0.0.39", 40 | "@types/jest": "^26.0.24", 41 | "@types/node": "^16.4.3", 42 | "@types/secp256k1": "^4.0.3", 43 | "@types/sqlstring": "^2.3.0", 44 | "concurrently": "^6.2.1", 45 | "jest": "^27.0.6", 46 | "stream-mock": "^2.0.5", 47 | "ts-jest": "^27.0.4", 48 | "wait-for-expect": "^3.0.2" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /mocks/utils/lnd-api.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@grpc/grpc-js"; 2 | import Long from "long"; 3 | import { Stream } from "stream"; 4 | import { DuplexMock, BufferWritableMock } from "stream-mock"; 5 | 6 | import { lnrpc } from "../../src/proto"; 7 | import { stringToUint8Array } from "../../src/utils/common"; 8 | 9 | export async function getInfo(lightning: Client) { 10 | const getInfoResponse = lnrpc.GetInfoResponse.create({ 11 | identityPubkey: "abc", 12 | }); 13 | return getInfoResponse; 14 | } 15 | 16 | export async function estimateFee(lightning: Client, amount: Long, targetConf: number) { 17 | const estimateFeeResponse = lnrpc.EstimateFeeResponse.create({ 18 | feeSat: Long.fromValue(10), 19 | feerateSatPerByte: Long.fromValue(100), 20 | }); 21 | return estimateFeeResponse; 22 | } 23 | 24 | let verifyMessageValidSig = true; 25 | export const __verifyMessageSetValidSig = (valid: boolean) => (verifyMessageValidSig = valid); 26 | export async function verifyMessage(lightning: Client, message: string, signature: string) { 27 | const verifyMessageResponse = lnrpc.VerifyMessageResponse.create({ 28 | pubkey: verifyMessageValidSig ? "abcdef12345" : "notvalidsig", 29 | }); 30 | return verifyMessageResponse; 31 | } 32 | 33 | export async function listPeers(lightning: Client) { 34 | const listPeersReponse = lnrpc.ListPeersResponse.create({ 35 | peers: [ 36 | { 37 | pubKey: "abcdef12345", 38 | }, 39 | ], 40 | }); 41 | return listPeersReponse; 42 | } 43 | 44 | export const openChannelSync = jest.fn(() => { 45 | const openChannelSyncResponse = lnrpc.ChannelPoint.create({ 46 | fundingTxidBytes: stringToUint8Array("abcdef"), 47 | outputIndex: 0, 48 | }); 49 | return openChannelSyncResponse; 50 | }); 51 | 52 | export const __htlcInterceptorStream = new DuplexMock(); 53 | export function htlcInterceptor(router: Client) { 54 | return __htlcInterceptorStream; 55 | } 56 | 57 | export const __subscribeHtlcEventsStream = new BufferWritableMock(); 58 | export function subscribeHtlcEvents(router: Client) { 59 | return __subscribeHtlcEventsStream; 60 | } 61 | 62 | export function subscribeChannelEvents(lightning: Client) { 63 | return new Stream(); 64 | } 65 | 66 | export const checkPeerConnected = jest.fn(() => { 67 | return true; 68 | }); 69 | 70 | export function subscribePeerEvents(lightning: Client) { 71 | return new Stream(); 72 | } 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📥 Lightning Box 2 | 3 | **Lightning Box is in a very early stage, not much is finished yet.** 4 | 5 | _Work In Progress, not suited for production just yet._ 6 | _Contributions, suggestions and ideas are appreciated._ 7 | _Database schema and configuration are bound to change._ 8 | 9 | Lightning Box is a payment inbox for [Lightning Addresses](https://lightningaddress.com). 10 | It's mainly suited for non-custodial Lightning wallets that might not always be online to receive payments. 11 | 12 | Lightning Box will take the payment on behalf of the wallet and then notify the user about the payment via a communication medium (Email, Telegram, Push notification...). The user is then supposed to start their wallet to withdraw. 13 | 14 | By utilizing the widely adopted protocols `LNURL-auth` and `LNURL-withdraw`, any supporting Lightning Wallet can use Lightning Box. 15 | Wallets that also support `LNURL-withdraw`'s [`balanceCheck`](https://github.com/fiatjaf/lnurl-rfc/blob/luds/14.md) can keep the Lightning Box as known service inside the wallet and easily withdraw from the box without leaving the wallet. 16 | 17 | For supporting wallets of [`bLIP-TBD`](https://github.com/hsjoberg/blips/blob/lnurl-forwarding/blip-lnurlforward.md), if the wallet is online, the `LNURL-pay` request will be forwarded to the wallet app via an LN P2P message, so that it can respond back with its own invoice. In this mode Lightning Box does not take the payment on behalf of the wallet. 18 | This mode is available in Blixt Wallet since version [0.6.9-420](https://github.com/hsjoberg/blixt-wallet/releases/tag/v0.6.9-420). 19 | 20 | ## Build 21 | 22 | Lightning Box requires lnd as the Lightning backend right now, though the plan is to 23 | make the service implementation independent. 24 | 25 | The `master` branch always expects the latest version of lnd. Lnd compiled with routerrpc is required. 26 | 27 | 1. Run lnd, wallet must be unlocked for Lightning Box to operate correctly 28 | 2. `git clone https://github.com/hsjoberg/lightning-box && cd lightning-box` 29 | 3. Copy `config/config.ts_TEMPLATE` to `config/config.ts` and set up your configuration. See config/interface.ts for documentation over the configuration 30 | 4. `npm install` 31 | 5. `npm start` 32 | 33 | # Test 34 | 35 | To do tests run `npm test` or `npm test:coverage`. 36 | 37 | Any new code should not decerease code coverage significantly. 38 | 39 | ## License 40 | 41 | MIT 42 | -------------------------------------------------------------------------------- /src/utils/grpc.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { Client, loadPackageDefinition, credentials, Metadata } from "@grpc/grpc-js"; 3 | import os from "os"; 4 | import * as protoLoader from "@grpc/proto-loader"; 5 | import { GrpcObject } from "@grpc/grpc-js/build/src/make-client"; 6 | import config from "../../config/config"; 7 | 8 | export const rpcImpl = (rpc: string, client: Client) => { 9 | return (method: any, requestData: any, callback: any) => { 10 | client.makeUnaryRequest( 11 | rpc + "/" + method.name, 12 | (arg: any) => arg, 13 | (arg: any) => arg, 14 | requestData, 15 | callback, 16 | ); 17 | }; 18 | }; 19 | 20 | export const getGrpcClients = () => { 21 | const loaderOptions = { 22 | keepCase: true, 23 | longs: String, 24 | enums: String, 25 | defaults: true, 26 | oneofs: true, 27 | }; 28 | const grpcServer = config.backendConfigLnd?.grpcServer; 29 | const tlsCert = config.backendConfigLnd?.cert.replace("~", os.homedir); 30 | const adminMacaroon = config.backendConfigLnd?.adminMacaroon.replace("~", os.homedir); 31 | // console.log(grpcServer, tlsCert, adminMacaroon); 32 | const packageDefinition = protoLoader.loadSync( 33 | ["./proto/lightning.proto", "./proto/router.proto"], 34 | loaderOptions, 35 | ); 36 | const lnrpcProto = loadPackageDefinition(packageDefinition).lnrpc as GrpcObject; 37 | const routerProto = loadPackageDefinition(packageDefinition).routerrpc as GrpcObject; 38 | const macaroon = fs.readFileSync(adminMacaroon ?? "").toString("hex"); 39 | process.env.GRPC_SSL_CIPHER_SUITES = "HIGH+ECDSA"; 40 | const lndCert = fs.readFileSync(tlsCert ?? ""); 41 | const sslCreds = credentials.createSsl(lndCert); 42 | let metadata = new Metadata(); 43 | metadata.add("macaroon", macaroon); 44 | const macaroonCreds = credentials.createFromMetadataGenerator((args: any, callback: any) => { 45 | callback(null, metadata); 46 | }); 47 | let callCreds = credentials.combineCallCredentials(macaroonCreds); 48 | let creds = credentials.combineChannelCredentials(sslCreds, macaroonCreds); 49 | 50 | let lightning = new (lnrpcProto as any).Lightning(grpcServer, creds) as Client; 51 | let router = new (routerProto as any).Router(grpcServer, creds) as Client; 52 | 53 | return { lightning, router }; 54 | }; 55 | 56 | export const grpcReqSerialize = (args: any) => args; 57 | 58 | export const grpcReqDeserialize = (args: any) => args; 59 | 60 | export const grpcMakeUnaryRequest = ( 61 | client: Client, 62 | method: string, 63 | argument: Uint8Array, 64 | decoder = (data: Uint8Array) => data as any, 65 | ): Promise => { 66 | return new Promise((resolve, reject) => { 67 | client.makeUnaryRequest(method, grpcReqSerialize, grpcReqDeserialize, argument, (err, res) => { 68 | if (err) { 69 | return reject(err); 70 | } 71 | resolve(decoder(res)); 72 | }); 73 | }); 74 | }; 75 | -------------------------------------------------------------------------------- /src/db/payment.ts: -------------------------------------------------------------------------------- 1 | import { Database } from "sqlite"; 2 | 3 | export interface IAuthenticationDB { 4 | userAlias: string; 5 | pubeky: string; 6 | } 7 | 8 | export interface IPaymentDB { 9 | paymentRequest: string; 10 | paymentRequestForward: string | null; 11 | userAlias: string; 12 | amountSat: number; 13 | settled: number; 14 | forwarded: number; 15 | comment: string | null; 16 | } 17 | 18 | export async function createPayment( 19 | db: Database, 20 | { 21 | paymentRequest, 22 | paymentRequestForward, 23 | userAlias, 24 | amountSat, 25 | settled, 26 | forwarded, 27 | comment, 28 | }: IPaymentDB, 29 | ) { 30 | await db.run( 31 | `INSERT INTO payment 32 | ( 33 | paymentRequest, 34 | paymentRequestForward, 35 | userAlias, 36 | amountSat, 37 | settled, 38 | forwarded, 39 | comment 40 | ) 41 | VALUES 42 | ( 43 | $paymentRequest, 44 | $paymentRequestForward, 45 | $userAlias, 46 | $amountSat, 47 | $settled, 48 | $forwarded, 49 | $comment 50 | ) 51 | `, 52 | { 53 | $paymentRequest: paymentRequest, 54 | $paymentRequestForward: paymentRequestForward, 55 | $userAlias: userAlias, 56 | $amountSat: amountSat, 57 | $settled: settled, 58 | $forwarded: forwarded, 59 | $comment: comment, 60 | }, 61 | ); 62 | } 63 | 64 | /** 65 | * Note: Updating paymentRequest, userAlias and comment is not allowed 66 | */ 67 | export async function updatePayment( 68 | db: Database, 69 | { paymentRequest, paymentRequestForward, settled, forwarded }: IPaymentDB, 70 | ) { 71 | await db.run( 72 | `UPDATE payment 73 | SET paymentRequestForward = $paymentRequestForward, 74 | settled = $settled, 75 | forwarded = $forwarded 76 | WHERE paymentRequest = $paymentRequest`, 77 | { 78 | $paymentRequestForward: paymentRequestForward, 79 | $settled: settled, 80 | $forwarded: forwarded, 81 | $paymentRequest: paymentRequest, 82 | }, 83 | ); 84 | } 85 | 86 | export function getPayment(db: Database, paymentRequest: string) { 87 | return db.get(`SELECT * FROM payment WHERE paymentRequest = $paymentRequest`, { 88 | $paymentRequest: paymentRequest, 89 | }); 90 | } 91 | 92 | export function getNonForwardedPayments(db: Database, userAlias: string) { 93 | return db.all( 94 | `SELECT * FROM payment WHERE userAlias = $userAlias AND settled = 1 AND forwarded = 0`, 95 | { 96 | $userAlias: userAlias, 97 | }, 98 | ); 99 | } 100 | 101 | // TODO not sure about race conditions with this one... 102 | export async function updatePaymentsSetAsForwarded( 103 | db: Database, 104 | userAlias: string, 105 | paymentRequestForward: string, 106 | ) { 107 | await db.run( 108 | `UPDATE payment 109 | SET paymentRequestForward = $paymentRequestForward, forwarded = 1 110 | WHERE userAlias = $userAlias AND settled = 1 AND forwarded = 0`, 111 | { 112 | $paymentRequestForward: paymentRequestForward, 113 | $userAlias: userAlias, 114 | }, 115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /src/utils/common.ts: -------------------------------------------------------------------------------- 1 | import { bech32 } from "bech32"; 2 | import { createHash, randomBytes } from "crypto"; 3 | import querystring from "querystring"; 4 | 5 | export const hexToUint8Array = (hexString: string) => { 6 | return new Uint8Array(hexString.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16))); 7 | }; 8 | 9 | export const stringToUint8Array = (str: string) => { 10 | return Uint8Array.from(str, (x) => x.charCodeAt(0)); 11 | }; 12 | 13 | export const bytesToString = (bytes: ArrayLike) => { 14 | return String.fromCharCode.apply(null, bytes as any); 15 | }; 16 | 17 | export function uint8ArrayToUnicodeString(ua: Uint8Array) { 18 | var binstr = Array.prototype.map 19 | .call(ua, function (ch) { 20 | return String.fromCharCode(ch); 21 | }) 22 | .join(""); 23 | var escstr = binstr.replace(/(.)/g, function (m, p) { 24 | var code = p.charCodeAt(0).toString(16).toUpperCase(); 25 | if (code.length < 2) { 26 | code = "0" + code; 27 | } 28 | return "%" + code; 29 | }); 30 | return decodeURIComponent(escstr); 31 | } 32 | 33 | export function unicodeStringToUint8Array(s: string) { 34 | var escstr = encodeURIComponent(s); 35 | var binstr = escstr.replace(/%([0-9A-F]{2})/g, function (match, p1) { 36 | return String.fromCharCode(("0x" + p1) as any); 37 | }); 38 | var ua = new Uint8Array(binstr.length); 39 | Array.prototype.forEach.call(binstr, function (ch, i) { 40 | ua[i] = ch.charCodeAt(0); 41 | }); 42 | return ua; 43 | } 44 | 45 | export const bytesToHexString = (bytes: Buffer | Uint8Array) => { 46 | // console.log("inside bytesToHexString"); 47 | // console.log(bytes); 48 | return bytes.reduce(function (memo, i) { 49 | return memo + ("0" + i.toString(16)).slice(-2); //padd with leading 0 if <16 50 | }, ""); 51 | }; 52 | 53 | export const generateBytes = (n: number): Promise => { 54 | return new Promise((resolve, reject) => { 55 | randomBytes(n, function (error, buffer) { 56 | if (error) { 57 | reject(error); 58 | return; 59 | } 60 | resolve(buffer); 61 | }); 62 | }); 63 | }; 64 | 65 | export const generateShortChannelId = (): Promise => { 66 | // According to https://github.com/lightningnetwork/lightning-rfc/blob/master/01-messaging.md#fundamental-types 67 | // `short_channel_id` is 8 byte 68 | return new Promise((resolve, reject) => { 69 | randomBytes(8, function (error, buffer) { 70 | if (error) { 71 | reject(error); 72 | return; 73 | } 74 | resolve(buffer.readUInt32BE()); 75 | }); 76 | }); 77 | }; 78 | 79 | export const timeout = (time: number) => 80 | new Promise((resolve) => setTimeout(() => resolve(void 0), time)); 81 | 82 | export function sha256(bytes: Uint8Array) { 83 | return createHash("sha256").update(bytes).digest("hex"); 84 | } 85 | 86 | export function sha256Buffer(bytes: Uint8Array) { 87 | return createHash("sha256").update(bytes).digest(); 88 | } 89 | 90 | export function randomIntegerRange(min: number, max: number) { 91 | return Math.floor(Math.random() * (max - min + 1)) + min; 92 | } 93 | 94 | export interface LnUrlAuthQuerystring { 95 | k1: string; 96 | sig: string; 97 | key: string; 98 | } 99 | 100 | export function createLnUrlAuth(k1: string, url: string) { 101 | const params = querystring.encode({ 102 | tag: "login", 103 | k1, 104 | }); 105 | return bech32.encode("lnurl", bech32.toWords(stringToUint8Array(url + "?" + params)), 1024); 106 | } 107 | 108 | export function isValidNodePubkey(pubKeyStr: string) { 109 | return /^[0-9a-fA-F]{66}$/.test(pubKeyStr); 110 | } 111 | -------------------------------------------------------------------------------- /src/api/withdraw.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsync } from "fastify"; 2 | import { Client } from "@grpc/grpc-js"; 3 | 4 | import { bytesToHexString, generateBytes } from "../utils/common"; 5 | import { 6 | getNonForwardedPayments, 7 | getPayment, 8 | updatePayment, 9 | updatePaymentsSetAsForwarded, 10 | } from "../db/payment"; 11 | import { decodePayReq, sendPaymentSync, subscribeInvoices } from "../utils/lnd-api"; 12 | import { MSAT } from "../utils/constants"; 13 | import getDb from "../db/db"; 14 | import { getWithdrawalCode } from "../db/withdrawalCode"; 15 | import config from "../../config/config"; 16 | import { lnrpc } from "../proto"; 17 | 18 | const Withdraw = async function (app, { lightning, router }) { 19 | const db = await getDb(); 20 | const withdrawalRequests = new Map(); 21 | 22 | const invoiceSubscription = subscribeInvoices(lightning); 23 | invoiceSubscription.on("data", async (data) => { 24 | console.log("\nINCOMING INVOICE"); 25 | const invoice = lnrpc.Invoice.decode(data); 26 | if (invoice.settled) { 27 | console.log("Settled"); 28 | 29 | // Check if this invoice relates to Lighting Box 30 | const payment = await getPayment(db, invoice.paymentRequest); 31 | if (payment) { 32 | console.log("Related payment"); 33 | await updatePayment(db, { 34 | paymentRequest: payment.paymentRequest, 35 | paymentRequestForward: null, 36 | userAlias: payment.userAlias, 37 | amountSat: invoice.amtPaid.toNumber(), 38 | settled: +invoice.settled, 39 | forwarded: 0, 40 | comment: payment.comment, 41 | }); 42 | } 43 | } 44 | }); 45 | 46 | app.get<{ 47 | Params: { code: string }; 48 | Querystring: { balanceCheck: string; single: string }; 49 | }>("/withdraw/:code", async (request, response) => { 50 | const code = request.params.code; 51 | const { balanceCheck, single } = request.query; 52 | 53 | const withdrawalCode = await getWithdrawalCode(db, code); 54 | if (!withdrawalCode) { 55 | return { 56 | status: "ERROR", 57 | reason: "Invalid withdrawal code.", 58 | }; 59 | } 60 | 61 | const payments = await getNonForwardedPayments(db, withdrawalCode.userAlias); 62 | const totalWithdrawalSat = payments.reduce((prev, curr) => prev + curr.amountSat, 0); 63 | if (totalWithdrawalSat <= 0 && balanceCheck === undefined) { 64 | return { 65 | status: "ERROR", 66 | reason: "No funds available.", 67 | }; 68 | } 69 | 70 | const k1 = bytesToHexString(await generateBytes(32)); 71 | withdrawalRequests.set(k1, code); 72 | 73 | const withdrawRequest: ILnUrlWithdrawRequest = { 74 | tag: "withdrawRequest", 75 | callback: `${config.domainUrl}/withdraw/${code}/callback`, 76 | defaultDescription: `Lightning Box: Withdrawal for ${withdrawalCode.userAlias}@${config.domain}`, 77 | k1, 78 | minWithdrawable: totalWithdrawalSat * MSAT, 79 | maxWithdrawable: totalWithdrawalSat * MSAT, 80 | balanceCheck: `${config.domainUrl}/withdraw/${code}?balanceCheck`, 81 | }; 82 | if (balanceCheck) { 83 | withdrawRequest.currentBalance = totalWithdrawalSat; 84 | } 85 | 86 | return withdrawRequest; 87 | }); 88 | 89 | app.get<{ 90 | Params: { code: string }; 91 | Querystring: ILnUrlWithdrawResponse; 92 | }>("/withdraw/:code/callback", async (request, response) => { 93 | const code = request.params.code; 94 | const withdrawResponse = request.query; 95 | 96 | const checkK1 = withdrawalRequests.get(withdrawResponse.k1); 97 | if (checkK1 !== code) { 98 | return { 99 | status: "ERROR", 100 | reason: "Invalid request.", 101 | }; 102 | } 103 | 104 | const withdrawalCode = await getWithdrawalCode(db, code); 105 | if (!withdrawalCode) { 106 | return { 107 | status: "ERROR", 108 | reason: "Invalid withdrawal code.", 109 | }; 110 | } 111 | 112 | if (!withdrawResponse.pr) { 113 | return { 114 | status: "ERROR", 115 | reason: "Missing parameter pr.", 116 | }; 117 | } 118 | 119 | const payments = await getNonForwardedPayments(db, withdrawalCode.userAlias); 120 | const totalWithdrawalSat = payments.reduce((prev, curr) => prev + curr.amountSat, 0); 121 | const paymentRequest = await decodePayReq(lightning, request.query.pr); 122 | 123 | if (totalWithdrawalSat <= 0) { 124 | return { 125 | status: "ERROR", 126 | reason: "No funds available.", 127 | }; 128 | } else if (paymentRequest.numMsat.eq(totalWithdrawalSat * 1000)) { 129 | response.send({ 130 | status: "OK", 131 | }); 132 | 133 | const result = await sendPaymentSync(lightning, withdrawResponse.pr); 134 | console.log(result); 135 | if (!result.paymentError || result.paymentError.length === 0) { 136 | await updatePaymentsSetAsForwarded(db, withdrawalCode.userAlias, withdrawResponse.pr); 137 | } 138 | withdrawalRequests.delete(withdrawResponse.k1); 139 | } else { 140 | response.send({ 141 | status: "ERROR", 142 | reason: "Amount mismatch", 143 | }); 144 | } 145 | }); 146 | } as FastifyPluginAsync<{ lightning: Client; router: Client }>; 147 | 148 | export default Withdraw; 149 | 150 | interface ILnUrlWithdrawRequest { 151 | tag: "withdrawRequest"; 152 | callback: string; 153 | k1: string; 154 | defaultDescription: string; 155 | minWithdrawable: number; 156 | maxWithdrawable: number; 157 | balanceCheck: string; 158 | currentBalance?: number; 159 | } 160 | 161 | interface ILnUrlWithdrawResponse { 162 | k1: string; 163 | pr: string; 164 | } 165 | -------------------------------------------------------------------------------- /tsconfig-tests.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es2017" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "CommonJS" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | "lib": [] /* Specify library files to be included in the compilation. */, 10 | "allowJs": true /* Allow javascript files to be compiled. */, 11 | "checkJs": false /* Report errors in .js files. */, 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./dist" /* Redirect output structure to the directory. */, 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 45 | "baseUrl": "." /* Base directory to resolve non-absolute module names. */, 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | "rootDirs": [] /* List of root folders whose combined content represents the structure of the project at runtime. */, 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true /* Skip type checking of declaration files. */, 67 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es2017" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "CommonJS" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | "lib": [] /* Specify library files to be included in the compilation. */, 10 | "allowJs": true /* Allow javascript files to be compiled. */, 11 | "checkJs": false /* Report errors in .js files. */, 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./dist" /* Redirect output structure to the directory. */, 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 45 | "baseUrl": "./src" /* Base directory to resolve non-absolute module names. */, 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | "rootDirs": [] /* List of root folders whose combined content represents the structure of the project at runtime. */, 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true /* Skip type checking of declaration files. */, 67 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 68 | }, 69 | "exclude": [ 70 | "dist", 71 | "tests", 72 | "mocks", 73 | "coverage", 74 | "./*.js", 75 | "src/services/admin/react-admin", 76 | "scripts" 77 | ] 78 | } 79 | -------------------------------------------------------------------------------- /src/utils/lnd-api.ts: -------------------------------------------------------------------------------- 1 | import { Client, Metadata } from "@grpc/grpc-js"; 2 | import Long from "long"; 3 | 4 | import { hexToUint8Array, stringToUint8Array, unicodeStringToUint8Array } from "./common"; 5 | import { lnrpc, routerrpc } from "../proto"; 6 | import { grpcMakeUnaryRequest } from "./grpc"; 7 | 8 | export async function addInvoice( 9 | lightning: Client, 10 | amountMsat: number, 11 | descriptionHash: Uint8Array, 12 | ) { 13 | const addInvoiceRequest = lnrpc.Invoice.encode({ 14 | valueMsat: Long.fromValue(amountMsat), 15 | descriptionHash, 16 | }).finish(); 17 | const response = await grpcMakeUnaryRequest( 18 | lightning, 19 | "/lnrpc.Lightning/AddInvoice", 20 | addInvoiceRequest, 21 | lnrpc.AddInvoiceResponse.decode, 22 | ); 23 | return response; 24 | } 25 | 26 | export function subscribeInvoices(lightning: Client) { 27 | const request = lnrpc.InvoiceSubscription.encode({}).finish(); 28 | return lightning.makeServerStreamRequest( 29 | "/lnrpc.Lightning/SubscribeInvoices", 30 | (arg: any) => arg, 31 | (arg) => arg, 32 | request, 33 | new Metadata(), 34 | undefined, 35 | ); 36 | } 37 | 38 | export async function decodePayReq(lightning: Client, payReq: string) { 39 | const decodePayReqRequest = lnrpc.PayReqString.encode({ 40 | payReq, 41 | }).finish(); 42 | const response = await grpcMakeUnaryRequest( 43 | lightning, 44 | "/lnrpc.Lightning/DecodePayReq", 45 | decodePayReqRequest, 46 | lnrpc.PayReq.decode, 47 | ); 48 | return response; 49 | } 50 | 51 | export async function sendPaymentSync(lightning: Client, paymentRequest: string) { 52 | const sendPaymentSyncRequest = lnrpc.SendRequest.encode({ 53 | paymentRequest, 54 | }).finish(); 55 | const response = await grpcMakeUnaryRequest( 56 | lightning, 57 | "/lnrpc.Lightning/SendPaymentSync", 58 | sendPaymentSyncRequest, 59 | lnrpc.SendResponse.decode, 60 | ); 61 | return response; 62 | } 63 | 64 | export async function getInfo(lightning: Client) { 65 | const getInfoRequest = lnrpc.GetInfoRequest.encode({}).finish(); 66 | const response = await grpcMakeUnaryRequest( 67 | lightning, 68 | "/lnrpc.Lightning/GetInfo", 69 | getInfoRequest, 70 | lnrpc.GetInfoResponse.decode, 71 | ); 72 | return response; 73 | } 74 | 75 | export async function estimateFee(lightning: Client, amount: Long, targetConf: number) { 76 | const estimateFeeRequest = lnrpc.EstimateFeeRequest.encode({ 77 | AddrToAmount: { 78 | tb1qsl4hhqs8skzwknqhwjcyyyjepnwmq8tlcd32m3: amount, 79 | tb1qud0w7nh5qh7azyjj0phssxzxp9zqdk8g0czwv6: Long.fromValue(10000), 80 | }, 81 | targetConf, 82 | }).finish(); 83 | 84 | const response = await grpcMakeUnaryRequest( 85 | lightning, 86 | "lnrpc.Lightning/EstimateFee", 87 | estimateFeeRequest, 88 | lnrpc.EstimateFeeResponse.decode, 89 | ); 90 | return response; 91 | } 92 | 93 | export async function signMessage(lightning: Client, message: string) { 94 | const signMessageRequest = lnrpc.SignMessageRequest.encode({ 95 | msg: stringToUint8Array(message), 96 | }).finish(); 97 | const response = await grpcMakeUnaryRequest( 98 | lightning, 99 | "/lnrpc.Lightning/SignMessage", 100 | signMessageRequest, 101 | lnrpc.SignMessageResponse.decode, 102 | ); 103 | return response; 104 | } 105 | 106 | export async function verifyMessage(lightning: Client, message: string, signature: string) { 107 | const verifyMessageRequest = lnrpc.VerifyMessageRequest.encode({ 108 | msg: stringToUint8Array(message), 109 | signature: signature, 110 | }).finish(); 111 | const response = await grpcMakeUnaryRequest( 112 | lightning, 113 | "/lnrpc.Lightning/VerifyMessage", 114 | verifyMessageRequest, 115 | lnrpc.VerifyMessageResponse.decode, 116 | ); 117 | return response; 118 | } 119 | 120 | export async function listPeers(lightning: Client) { 121 | const listPeersRequest = lnrpc.ListPeersRequest.encode({}).finish(); 122 | const response = await grpcMakeUnaryRequest( 123 | lightning, 124 | "/lnrpc.Lightning/ListPeers", 125 | listPeersRequest, 126 | lnrpc.ListPeersResponse.decode, 127 | ); 128 | return response; 129 | } 130 | 131 | export async function openChannelSync( 132 | lightning: Client, 133 | pubkey: string, 134 | localFundingAmount: Long, 135 | pushSat: Long, 136 | privateChannel: boolean, 137 | spendUnconfirmed: boolean, 138 | ) { 139 | const openChannelSyncRequest = lnrpc.OpenChannelRequest.encode({ 140 | nodePubkey: hexToUint8Array(pubkey), 141 | localFundingAmount, 142 | pushSat, 143 | targetConf: 1, 144 | minConfs: 0, 145 | private: privateChannel, 146 | spendUnconfirmed, 147 | }).finish(); 148 | 149 | return await grpcMakeUnaryRequest( 150 | lightning, 151 | "lnrpc.Lightning/OpenChannelSync", 152 | openChannelSyncRequest, 153 | lnrpc.ChannelPoint.decode, 154 | ); 155 | } 156 | 157 | export function htlcInterceptor(router: Client) { 158 | return router.makeBidiStreamRequest( 159 | "/routerrpc.Router/HtlcInterceptor", 160 | (arg: any) => arg, 161 | (arg) => arg, 162 | new Metadata(), 163 | undefined, 164 | ); 165 | } 166 | 167 | export function subscribeHtlcEvents(router: Client) { 168 | const request = routerrpc.SubscribeHtlcEventsRequest.encode({}).finish(); 169 | return router.makeServerStreamRequest( 170 | "/routerrpc.Router/SubscribeHtlcEvents", 171 | (arg: any) => arg, 172 | (arg) => arg, 173 | request, 174 | new Metadata(), 175 | undefined, 176 | ); 177 | } 178 | 179 | export function subscribeChannelEvents(lightning: Client) { 180 | const request = lnrpc.ChannelEventSubscription.encode({}).finish(); 181 | return lightning.makeServerStreamRequest( 182 | "/routerrpc.Lightning/SubscribeChannelEvents", 183 | (arg: any) => arg, 184 | (arg) => arg, 185 | request, 186 | new Metadata(), 187 | undefined, 188 | ); 189 | } 190 | 191 | export async function checkPeerConnected(lightning: Client, pubkey: string) { 192 | const listPeersResponse = await listPeers(lightning); 193 | const seekPeer = listPeersResponse.peers.find((peer) => { 194 | return peer.pubKey === pubkey; 195 | }); 196 | 197 | return !!seekPeer; 198 | } 199 | 200 | export async function pendingChannels(lightning: Client) { 201 | const pendingChannelsRequest = lnrpc.PendingChannelsRequest.encode({}).finish(); 202 | const response = await grpcMakeUnaryRequest( 203 | lightning, 204 | "/lnrpc.Lightning/PendingChannels", 205 | pendingChannelsRequest, 206 | lnrpc.PendingChannelsResponse.decode, 207 | ); 208 | return response; 209 | } 210 | 211 | export async function listChannels(lightning: Client, peer?: Uint8Array) { 212 | const listChannelsRequest = lnrpc.ListChannelsRequest.encode({ 213 | peer, 214 | }).finish(); 215 | const response = await grpcMakeUnaryRequest( 216 | lightning, 217 | "/lnrpc.Lightning/ListChannels", 218 | listChannelsRequest, 219 | lnrpc.ListChannelsResponse.decode, 220 | ); 221 | return response; 222 | } 223 | 224 | export function subscribePeerEvents(lightning: Client) { 225 | const request = lnrpc.PeerEventSubscription.encode({}).finish(); 226 | return lightning.makeServerStreamRequest( 227 | "/lnrpc.Lightning/SubscribePeerEvents", 228 | (arg: any) => arg, 229 | (arg) => arg, 230 | request, 231 | new Metadata(), 232 | undefined, 233 | ); 234 | } 235 | 236 | export async function sendCustomMessage( 237 | lightning: Client, 238 | peerPubkey: string, 239 | type: number, 240 | dataString: string, 241 | ) { 242 | const sendCustomMessageRequest = lnrpc.SendCustomMessageRequest.encode({ 243 | peer: hexToUint8Array(peerPubkey), 244 | type, 245 | data: unicodeStringToUint8Array(dataString), 246 | }).finish(); 247 | const response = await grpcMakeUnaryRequest( 248 | lightning, 249 | "/lnrpc.Lightning/SendCustomMessage", 250 | sendCustomMessageRequest, 251 | lnrpc.SendCustomMessageResponse.decode, 252 | ); 253 | return response; 254 | } 255 | 256 | export function SubscribeCustomMessages(lightning: Client) { 257 | const request = lnrpc.SubscribeCustomMessagesRequest.encode({}).finish(); 258 | return lightning.makeServerStreamRequest( 259 | "/lnrpc.Lightning/SubscribeCustomMessages", 260 | (arg: any) => arg, 261 | (arg) => arg, 262 | request, 263 | new Metadata(), 264 | undefined, 265 | ); 266 | } 267 | -------------------------------------------------------------------------------- /src/api/user/manage.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsync } from "fastify"; 2 | import { Client } from "@grpc/grpc-js"; 3 | import { getUnixTime } from "date-fns"; 4 | 5 | import { hexToUint8Array, isValidNodePubkey } from "../../utils/common"; 6 | import { listChannels, verifyMessage } from "../../utils/lnd-api"; 7 | import getDb from "../../db/db"; 8 | import { createUser, getUserByAlias, getUserByPubkey } from "../../db/user"; 9 | import config from "../../../config/config"; 10 | 11 | interface ISignedMessage { 12 | nonce?: string; 13 | endpoint?: string; 14 | timestamp?: number; // This is a unix timestamp 15 | data: { [k: string]: any }; 16 | } 17 | 18 | const User = async function (app, { lightning, router }) { 19 | const db = await getDb(); 20 | 21 | /** 22 | * 23 | * Each message is signed by the wallet's key. We therefore get their pubkey from the signature. 24 | * 25 | * In order to prevent replay attacks, the signed message is a nonce + UNIX timestamp. 26 | * Requests older than 30 seconds will be rejected. 27 | * 28 | */ 29 | 30 | /** 31 | * 32 | * POST /user/get-user 33 | * 34 | * Get information about oneself. Returns error if there is no account. 35 | * TODO remove this one? 36 | * 37 | */ 38 | interface IGetUserRequest { 39 | signature?: string; 40 | message?: string; 41 | } 42 | const getUserEndpoint = "/user/get-user"; 43 | app.post(getUserEndpoint, async (request, response) => { 44 | const getUserRequest = JSON.parse(request.body as string) as IGetUserRequest; // TODO this sucks 45 | 46 | const verificationResult = await verifyMessage( 47 | lightning, 48 | getUserRequest.message!, 49 | getUserRequest.signature!, 50 | ); // TODO this sucks 51 | 52 | const signedMessage = JSON.parse(getUserRequest.message!) as ISignedMessage; // TODO this sucks 53 | 54 | const pubkey = verificationResult.pubkey; 55 | if (!pubkey) { 56 | response.code(400); 57 | return { 58 | status: "ERROR", 59 | reason: "Invalid signature", 60 | }; 61 | } 62 | 63 | if (signedMessage.endpoint !== getUserEndpoint) { 64 | response.code(400); 65 | return { 66 | status: "ERROR", 67 | reason: "Invalid request", 68 | }; 69 | } 70 | 71 | const currentTime = Math.floor(Date.now() / 1000); 72 | const timeDiff = currentTime - (signedMessage.timestamp ?? 0); 73 | if (timeDiff < -30 || timeDiff > 30) { 74 | response.code(400); 75 | return { 76 | status: "ERROR", 77 | reason: "Request is either too old or from the future.", 78 | }; 79 | } 80 | 81 | const user = await getUserByAlias(db, pubkey); 82 | 83 | if (!user) { 84 | response.code(400); 85 | return { 86 | status: "ERROR", 87 | code: "NO_USER", 88 | reason: `You have no user.`, 89 | }; 90 | } 91 | 92 | return { 93 | status: "OK", 94 | user: { 95 | alias: user.alias, 96 | lightningAddress: `${user.alias}@${config.domain}`, 97 | pubkey: user.pubkey, 98 | }, 99 | }; 100 | }); 101 | 102 | /** 103 | * 104 | * POST /user/check-eligibility 105 | * 106 | * Check whether the wallet is eligible for registration. 107 | * The current requirement is to have a channel with the Lightning Box LSP. 108 | * If the wallet has an account already, an error will be returned with a user object. 109 | * 110 | */ 111 | interface ICheckEligibilityRequest { 112 | signature?: string; 113 | message?: string; 114 | } 115 | const checkEligibilityEndopint = "/user/check-eligibility"; 116 | app.post(checkEligibilityEndopint, async (request, response) => { 117 | const registerRequest = JSON.parse(request.body as string) as ICheckEligibilityRequest; // TODO this sucks 118 | 119 | const verificationResult = await verifyMessage( 120 | lightning, 121 | registerRequest.message!, 122 | registerRequest.signature!, 123 | ); // TODO this sucks 124 | 125 | const pubkey = verificationResult.pubkey; 126 | if (!pubkey) { 127 | response.code(400); 128 | return { 129 | status: "ERROR", 130 | reason: "Invalid signature.", 131 | }; 132 | } 133 | 134 | const signedMessage = JSON.parse(registerRequest.message!) as ISignedMessage; // TODO this sucks 135 | if (signedMessage.endpoint !== checkEligibilityEndopint) { 136 | response.code(400); 137 | return { 138 | status: "ERROR", 139 | reason: "Invalid request", 140 | }; 141 | } 142 | 143 | const currentTime = Math.floor(Date.now() / 1000); 144 | const timeDiff = currentTime - (signedMessage.timestamp ?? 0); 145 | if (timeDiff < -30 || timeDiff > 30) { 146 | response.code(400); 147 | return { 148 | status: "ERROR", 149 | reason: "Request is either too old or from the future.", 150 | }; 151 | } 152 | 153 | const user = await getUserByPubkey(db, pubkey); 154 | if (user) { 155 | response.code(400); 156 | return { 157 | status: "ERROR", 158 | code: "HAS_USER", 159 | reason: `You have a user already.`, 160 | user: { 161 | alias: user.alias, 162 | lightningAddress: `${user.alias}@${config.domain}`, 163 | pubkey: user.pubkey, 164 | }, 165 | }; 166 | } 167 | 168 | if (await checkIfWalletHasChannel(lightning, hexToUint8Array(pubkey))) { 169 | return { 170 | status: "OK", 171 | }; 172 | } else { 173 | response.code(400); 174 | return { 175 | status: "ERROR", 176 | reason: "You need a channel with the Lightning Box service.", 177 | }; 178 | } 179 | }); 180 | 181 | /** 182 | * 183 | * POST /user/register 184 | * 185 | * Register a Lightning Box account. 186 | * 187 | */ 188 | interface IRegisterRequest { 189 | name?: string; 190 | signature?: string; 191 | message?: string; 192 | } 193 | const registerEndpoint = "/user/register"; 194 | app.post(registerEndpoint, async (request, response) => { 195 | const registerRequest = JSON.parse(request.body as string) as IRegisterRequest; // TODO this sucks 196 | 197 | const verificationResult = await verifyMessage( 198 | lightning, 199 | registerRequest.message!, 200 | registerRequest.signature!, 201 | ); // TODO this sucks 202 | 203 | const pubkey = verificationResult.pubkey; 204 | if (!pubkey) { 205 | response.code(400); 206 | return { 207 | status: "ERROR", 208 | reason: "Invalid signature.", 209 | }; 210 | } 211 | 212 | const signedMessage = JSON.parse(registerRequest.message!) as ISignedMessage; // TODO this sucks 213 | 214 | if (signedMessage.endpoint !== registerEndpoint) { 215 | response.code(400); 216 | return { 217 | status: "ERROR", 218 | reason: "Invalid request", 219 | }; 220 | } 221 | 222 | const currentTime = Math.floor(Date.now() / 1000); 223 | const timeDiff = currentTime - (signedMessage.timestamp ?? 0); 224 | if (timeDiff < -30 || timeDiff > 30) { 225 | response.code(400); 226 | return { 227 | status: "ERROR", 228 | reason: "Request is either too old or from the future.", 229 | }; 230 | } 231 | 232 | if (await getUserByPubkey(db, pubkey)) { 233 | response.code(400); 234 | return { 235 | status: "ERROR", 236 | code: "HAS_USER", 237 | reason: `You have a user already.`, 238 | }; 239 | } 240 | 241 | let alias = signedMessage.data?.name; 242 | 243 | if (!alias) { 244 | response.code(400); 245 | return { 246 | status: "ERROR", 247 | reason: "Alias missing.", 248 | }; 249 | } 250 | 251 | alias = alias.toLowerCase(); 252 | 253 | if (alias === "satoshi") { 254 | response.code(400); 255 | return { 256 | status: "ERROR", 257 | reason: "Nah. Don't claim to be satoshi.", 258 | }; 259 | } else if (isValidNodePubkey(alias)) { 260 | response.code(400); 261 | return { 262 | status: "ERROR", 263 | reason: "Your username can't be a Lightning node pubkey.", 264 | }; 265 | } 266 | 267 | if (!sanitizeLightningAddress(alias)) { 268 | response.code(400); 269 | return { 270 | status: "ERROR", 271 | reason: "Lightning Address must to be alphanumeric and between 4-16 symbols.", 272 | }; 273 | } 274 | 275 | if (await getUserByAlias(db, alias)) { 276 | response.code(400); 277 | return { 278 | status: "ERROR", 279 | reason: `Alias ${alias} already in use. Choose another one.`, 280 | }; 281 | } 282 | 283 | if (!(await checkIfWalletHasChannel(lightning, hexToUint8Array(pubkey)))) { 284 | response.code(400); 285 | return { 286 | status: "ERROR", 287 | reason: "You need a channel with the Lightning Box LSP.", 288 | }; 289 | } 290 | 291 | await createUser(db, { alias, pubkey: pubkey }); 292 | 293 | return { 294 | status: "OK", 295 | user: { 296 | alias, 297 | lightningAddress: `${alias}@${config.domain}`, 298 | pubkey, 299 | }, 300 | }; 301 | }); 302 | } as FastifyPluginAsync<{ 303 | lightning: Client; 304 | router: Client; 305 | }>; 306 | 307 | export default User; 308 | 309 | function sanitizeLightningAddress(subject: string) { 310 | return /^[a-z0-9]{4,16}$/.test(subject); 311 | } 312 | 313 | async function checkIfWalletHasChannel(lightning: Client, pubkey: Uint8Array) { 314 | const channels = await listChannels(lightning, pubkey); 315 | return channels.channels?.length > 0; 316 | } 317 | -------------------------------------------------------------------------------- /proto/walletunlocker.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "rpc.proto"; 4 | 5 | package lnrpc; 6 | 7 | option go_package = "github.com/lightningnetwork/lnd/lnrpc"; 8 | 9 | /* 10 | * Comments in this file will be directly parsed into the API 11 | * Documentation as descriptions of the associated method, message, or field. 12 | * These descriptions should go right above the definition of the object, and 13 | * can be in either block or // comment format. 14 | * 15 | * An RPC method can be matched to an lncli command by placing a line in the 16 | * beginning of the description in exactly the following format: 17 | * lncli: `methodname` 18 | * 19 | * Failure to specify the exact name of the command will cause documentation 20 | * generation to fail. 21 | * 22 | * More information on how exactly the gRPC documentation is generated from 23 | * this proto file can be found here: 24 | * https://github.com/lightninglabs/lightning-api 25 | */ 26 | 27 | // WalletUnlocker is a service that is used to set up a wallet password for 28 | // lnd at first startup, and unlock a previously set up wallet. 29 | service WalletUnlocker { 30 | /* 31 | GenSeed is the first method that should be used to instantiate a new lnd 32 | instance. This method allows a caller to generate a new aezeed cipher seed 33 | given an optional passphrase. If provided, the passphrase will be necessary 34 | to decrypt the cipherseed to expose the internal wallet seed. 35 | 36 | Once the cipherseed is obtained and verified by the user, the InitWallet 37 | method should be used to commit the newly generated seed, and create the 38 | wallet. 39 | */ 40 | rpc GenSeed (GenSeedRequest) returns (GenSeedResponse); 41 | 42 | /* 43 | InitWallet is used when lnd is starting up for the first time to fully 44 | initialize the daemon and its internal wallet. At the very least a wallet 45 | password must be provided. This will be used to encrypt sensitive material 46 | on disk. 47 | 48 | In the case of a recovery scenario, the user can also specify their aezeed 49 | mnemonic and passphrase. If set, then the daemon will use this prior state 50 | to initialize its internal wallet. 51 | 52 | Alternatively, this can be used along with the GenSeed RPC to obtain a 53 | seed, then present it to the user. Once it has been verified by the user, 54 | the seed can be fed into this RPC in order to commit the new wallet. 55 | */ 56 | rpc InitWallet (InitWalletRequest) returns (InitWalletResponse); 57 | 58 | /* lncli: `unlock` 59 | UnlockWallet is used at startup of lnd to provide a password to unlock 60 | the wallet database. 61 | */ 62 | rpc UnlockWallet (UnlockWalletRequest) returns (UnlockWalletResponse); 63 | 64 | /* lncli: `changepassword` 65 | ChangePassword changes the password of the encrypted wallet. This will 66 | automatically unlock the wallet database if successful. 67 | */ 68 | rpc ChangePassword (ChangePasswordRequest) returns (ChangePasswordResponse); 69 | } 70 | 71 | message GenSeedRequest { 72 | /* 73 | aezeed_passphrase is an optional user provided passphrase that will be used 74 | to encrypt the generated aezeed cipher seed. When using REST, this field 75 | must be encoded as base64. 76 | */ 77 | bytes aezeed_passphrase = 1; 78 | 79 | /* 80 | seed_entropy is an optional 16-bytes generated via CSPRNG. If not 81 | specified, then a fresh set of randomness will be used to create the seed. 82 | When using REST, this field must be encoded as base64. 83 | */ 84 | bytes seed_entropy = 2; 85 | } 86 | message GenSeedResponse { 87 | /* 88 | cipher_seed_mnemonic is a 24-word mnemonic that encodes a prior aezeed 89 | cipher seed obtained by the user. This field is optional, as if not 90 | provided, then the daemon will generate a new cipher seed for the user. 91 | Otherwise, then the daemon will attempt to recover the wallet state linked 92 | to this cipher seed. 93 | */ 94 | repeated string cipher_seed_mnemonic = 1; 95 | 96 | /* 97 | enciphered_seed are the raw aezeed cipher seed bytes. This is the raw 98 | cipher text before run through our mnemonic encoding scheme. 99 | */ 100 | bytes enciphered_seed = 2; 101 | } 102 | 103 | message InitWalletRequest { 104 | /* 105 | wallet_password is the passphrase that should be used to encrypt the 106 | wallet. This MUST be at least 8 chars in length. After creation, this 107 | password is required to unlock the daemon. When using REST, this field 108 | must be encoded as base64. 109 | */ 110 | bytes wallet_password = 1; 111 | 112 | /* 113 | cipher_seed_mnemonic is a 24-word mnemonic that encodes a prior aezeed 114 | cipher seed obtained by the user. This may have been generated by the 115 | GenSeed method, or be an existing seed. 116 | */ 117 | repeated string cipher_seed_mnemonic = 2; 118 | 119 | /* 120 | aezeed_passphrase is an optional user provided passphrase that will be used 121 | to encrypt the generated aezeed cipher seed. When using REST, this field 122 | must be encoded as base64. 123 | */ 124 | bytes aezeed_passphrase = 3; 125 | 126 | /* 127 | recovery_window is an optional argument specifying the address lookahead 128 | when restoring a wallet seed. The recovery window applies to each 129 | individual branch of the BIP44 derivation paths. Supplying a recovery 130 | window of zero indicates that no addresses should be recovered, such after 131 | the first initialization of the wallet. 132 | */ 133 | int32 recovery_window = 4; 134 | 135 | /* 136 | channel_backups is an optional argument that allows clients to recover the 137 | settled funds within a set of channels. This should be populated if the 138 | user was unable to close out all channels and sweep funds before partial or 139 | total data loss occurred. If specified, then after on-chain recovery of 140 | funds, lnd begin to carry out the data loss recovery protocol in order to 141 | recover the funds in each channel from a remote force closed transaction. 142 | */ 143 | ChanBackupSnapshot channel_backups = 5; 144 | 145 | /* 146 | stateless_init is an optional argument instructing the daemon NOT to create 147 | any *.macaroon files in its filesystem. If this parameter is set, then the 148 | admin macaroon returned in the response MUST be stored by the caller of the 149 | RPC as otherwise all access to the daemon will be lost! 150 | */ 151 | bool stateless_init = 6; 152 | } 153 | message InitWalletResponse { 154 | /* 155 | The binary serialized admin macaroon that can be used to access the daemon 156 | after creating the wallet. If the stateless_init parameter was set to true, 157 | this is the ONLY copy of the macaroon and MUST be stored safely by the 158 | caller. Otherwise a copy of this macaroon is also persisted on disk by the 159 | daemon, together with other macaroon files. 160 | */ 161 | bytes admin_macaroon = 1; 162 | } 163 | 164 | message UnlockWalletRequest { 165 | /* 166 | wallet_password should be the current valid passphrase for the daemon. This 167 | will be required to decrypt on-disk material that the daemon requires to 168 | function properly. When using REST, this field must be encoded as base64. 169 | */ 170 | bytes wallet_password = 1; 171 | 172 | /* 173 | recovery_window is an optional argument specifying the address lookahead 174 | when restoring a wallet seed. The recovery window applies to each 175 | individual branch of the BIP44 derivation paths. Supplying a recovery 176 | window of zero indicates that no addresses should be recovered, such after 177 | the first initialization of the wallet. 178 | */ 179 | int32 recovery_window = 2; 180 | 181 | /* 182 | channel_backups is an optional argument that allows clients to recover the 183 | settled funds within a set of channels. This should be populated if the 184 | user was unable to close out all channels and sweep funds before partial or 185 | total data loss occurred. If specified, then after on-chain recovery of 186 | funds, lnd begin to carry out the data loss recovery protocol in order to 187 | recover the funds in each channel from a remote force closed transaction. 188 | */ 189 | ChanBackupSnapshot channel_backups = 3; 190 | 191 | /* 192 | stateless_init is an optional argument instructing the daemon NOT to create 193 | any *.macaroon files in its file system. 194 | */ 195 | bool stateless_init = 4; 196 | } 197 | message UnlockWalletResponse { 198 | } 199 | 200 | message ChangePasswordRequest { 201 | /* 202 | current_password should be the current valid passphrase used to unlock the 203 | daemon. When using REST, this field must be encoded as base64. 204 | */ 205 | bytes current_password = 1; 206 | 207 | /* 208 | new_password should be the new passphrase that will be needed to unlock the 209 | daemon. When using REST, this field must be encoded as base64. 210 | */ 211 | bytes new_password = 2; 212 | 213 | /* 214 | stateless_init is an optional argument instructing the daemon NOT to create 215 | any *.macaroon files in its filesystem. If this parameter is set, then the 216 | admin macaroon returned in the response MUST be stored by the caller of the 217 | RPC as otherwise all access to the daemon will be lost! 218 | */ 219 | bool stateless_init = 3; 220 | 221 | /* 222 | new_macaroon_root_key is an optional argument instructing the daemon to 223 | rotate the macaroon root key when set to true. This will invalidate all 224 | previously generated macaroons. 225 | */ 226 | bool new_macaroon_root_key = 4; 227 | } 228 | message ChangePasswordResponse { 229 | /* 230 | The binary serialized admin macaroon that can be used to access the daemon 231 | after rotating the macaroon root key. If both the stateless_init and 232 | new_macaroon_root_key parameter were set to true, this is the ONLY copy of 233 | the macaroon that was created from the new root key and MUST be stored 234 | safely by the caller. Otherwise a copy of this macaroon is also persisted on 235 | disk by the daemon, together with other macaroon files. 236 | */ 237 | bytes admin_macaroon = 1; 238 | } 239 | -------------------------------------------------------------------------------- /src/api/pay.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsync, FastifyReply } from "fastify"; 2 | import { Client } from "@grpc/grpc-js"; 3 | import crypto from "crypto"; 4 | 5 | import { 6 | addInvoice, 7 | checkPeerConnected, 8 | sendCustomMessage, 9 | SubscribeCustomMessages, 10 | } from "../utils/lnd-api"; 11 | import { createPayment } from "../db/payment"; 12 | import { getUserByAlias, getUserByPubkey, IUserDB } from "../db/user"; 13 | import { MSAT } from "../utils/constants"; 14 | import getDb from "../db/db"; 15 | import config from "../../config/config"; 16 | import { lnrpc } from "../proto"; 17 | import { bytesToString, uint8ArrayToUnicodeString } from "../utils/common"; 18 | 19 | let lnurlPayForwardingRequestCounter = 0; 20 | const lnurlPayForwardingRequests = new Map< 21 | number, 22 | { response: FastifyReply; pubkey: string; alias: string } 23 | >(); 24 | 25 | const LnurlPayRequestLNP2PType = 32768 + 691; 26 | 27 | interface ILnurlPayForwardP2PMessage { 28 | id: number; 29 | request: 30 | | "LNURLPAY_REQUEST1" 31 | | "LNURLPAY_REQUEST1_RESPONSE" 32 | | "LNURLPAY_REQUEST2" 33 | | "LNURLPAY_REQUEST2_RESPONSE"; 34 | data: any; 35 | metadata?: any; 36 | } 37 | 38 | const Pay = async function (app, { lightning, router }) { 39 | const db = await getDb(); 40 | 41 | // This is used for LNURL-pay forwarding to the wallet 42 | const customMessagesSubscription = SubscribeCustomMessages(lightning); 43 | customMessagesSubscription.on("data", async (data) => { 44 | customMessageHandler(data); 45 | }); 46 | 47 | app.get<{ 48 | Params: { 49 | username: string; 50 | }; 51 | }>("/.well-known/lnurlp/:username", async (request, response) => { 52 | const username = request.params.username; 53 | const user = (await getUserByAlias(db, username)) ?? (await getUserByPubkey(db, username)); 54 | if (!user) { 55 | response.code(400); 56 | return { 57 | status: "ERROR", 58 | reason: `The recipient ${username}@${config.domain} does not exist.`, 59 | }; 60 | } 61 | 62 | // If the peer is connected, forward the LNURL-pay request via LN P2P. 63 | if (await checkPeerConnected(lightning, user.pubkey)) { 64 | await handleLnurlPayRequest1Forwarding(lightning, user, username, response); 65 | return; 66 | } else if (config.disableCustodial) { 67 | return { 68 | status: "ERROR", 69 | reason: `It's not possible to pay ${username}@${config.domain} at this time.`, 70 | }; 71 | } 72 | 73 | return { 74 | tag: "payRequest", 75 | callback: `${config.domainUrl}/lightning-address/${username}/send`, 76 | minSendable: 1 * MSAT, 77 | maxSendable: 1000000 * MSAT, 78 | metadata: JSON.stringify(constructLnUrlPayMetaData(username, config.domain)), 79 | commentAllowed: 144, 80 | }; 81 | }); 82 | 83 | app.get<{ 84 | Params: { 85 | username: string; 86 | }; 87 | Querystring: ILnUrlPayParams; 88 | }>("/lightning-address/:username/send", async (request, response) => { 89 | try { 90 | const username = request.params.username; 91 | const user = (await getUserByAlias(db, username)) ?? (await getUserByPubkey(db, username)); 92 | if (!user) { 93 | response.code(400); 94 | return { 95 | status: "ERROR", 96 | reason: `The recipient ${username}@${config.domain} does not exist.`, 97 | }; 98 | } 99 | 100 | const { amount, comment, payerData } = parseSendTextCallbackQueryParams(request.query); 101 | 102 | if (await checkPeerConnected(lightning, user.pubkey)) { 103 | await handleLnurlPayRequest2Forwarding( 104 | lightning, 105 | user, 106 | username, 107 | amount, 108 | comment, 109 | payerData, 110 | response, 111 | ); 112 | return; 113 | } else if (config.disableCustodial) { 114 | return { 115 | status: "ERROR", 116 | reason: `Unknown error occured.`, 117 | }; 118 | } 119 | 120 | if (comment && comment.length > 144) { 121 | console.error("Got invalid comment length"); 122 | response.code(400); 123 | return { 124 | status: "ERROR", 125 | reason: "Comment cannot be larger than 144 letters.", 126 | }; 127 | } 128 | 129 | // TODO check amount 130 | 131 | const invoice = await addInvoice( 132 | lightning, 133 | amount, 134 | crypto 135 | .createHash("sha256") 136 | .update(JSON.stringify(constructLnUrlPayMetaData(username, config.domain))) 137 | .digest(), 138 | ); 139 | 140 | await createPayment(db, { 141 | paymentRequest: invoice.paymentRequest, 142 | paymentRequestForward: null, 143 | userAlias: username, 144 | amountSat: amount / MSAT, 145 | forwarded: 0, 146 | settled: 0, 147 | comment: comment ?? null, 148 | }); 149 | 150 | return { 151 | pr: invoice.paymentRequest, 152 | successAction: null, 153 | disposable: true, 154 | }; 155 | } catch (error) { 156 | response.code(500); 157 | return { 158 | status: "ERROR", 159 | reason: error.message, 160 | }; 161 | } 162 | }); 163 | } as FastifyPluginAsync<{ lightning: Client; router: Client }>; 164 | 165 | export default Pay; 166 | 167 | type Metadata = [string, string][]; 168 | 169 | function constructLnUrlPayMetaData(username: string, domain: string): Metadata { 170 | return [ 171 | ["text/plain", `${username}@${domain}: Thank you for the sats!`], 172 | ["text/identifier", `${username}@${domain}`], 173 | ]; 174 | } 175 | 176 | interface ILnUrlPayParams { 177 | amount: number; 178 | comment?: string; 179 | payerData?: string; 180 | } 181 | 182 | function parseSendTextCallbackQueryParams(params: any): ILnUrlPayParams { 183 | try { 184 | return { 185 | amount: Number.parseInt(params.amount ?? "0", 10), 186 | comment: params.comment ?? "", 187 | payerData: params.payerdata, 188 | }; 189 | } catch (e) { 190 | console.error(e); 191 | throw new Error("Could not parse query params"); 192 | } 193 | } 194 | 195 | function customMessageHandler(data: any) { 196 | console.log("\nINCOMING CUSTOM MESSAGE"); 197 | 198 | try { 199 | const customMessage = lnrpc.CustomMessage.decode(data); 200 | if (customMessage.type !== LnurlPayRequestLNP2PType) { 201 | throw new Error(`Unknown custom message type ${customMessage.type}`); 202 | } 203 | const request = JSON.parse( 204 | uint8ArrayToUnicodeString(customMessage.data), 205 | ) as ILnurlPayForwardP2PMessage; 206 | console.log(request); 207 | 208 | if (request.request === "LNURLPAY_REQUEST1_RESPONSE") { 209 | if (!lnurlPayForwardingRequests.has(request.id)) { 210 | console.error(`Unknown LNURL-pay forwarding request ${request.id}`); 211 | return; 212 | } 213 | const lnurlPayForwardingRequest = lnurlPayForwardingRequests.get(request.id); 214 | lnurlPayForwardingRequests.delete(request.id); 215 | 216 | lnurlPayForwardingRequest?.response.send({ 217 | ...request.data, 218 | callback: `${config.domainUrl}/lightning-address/${lnurlPayForwardingRequest?.alias}/send`, 219 | }); 220 | } else if (request.request === "LNURLPAY_REQUEST2_RESPONSE") { 221 | const customMessage = lnrpc.CustomMessage.decode(data); 222 | const request = JSON.parse(uint8ArrayToUnicodeString(customMessage.data)); 223 | if (!lnurlPayForwardingRequests.has(request.id)) { 224 | console.error(`Unknown LNURL-pay forwarding callback request ${request.id}`); 225 | return; 226 | } 227 | const lnurlPayForwardingRequest = lnurlPayForwardingRequests.get(request.id); 228 | lnurlPayForwardingRequests.delete(request.id); 229 | 230 | lnurlPayForwardingRequest?.response.send(request.data); 231 | } else { 232 | console.error("Unknown message", request); 233 | } 234 | } catch (error) { 235 | console.error(`Error when handling custom message: ${error.message}`); 236 | } 237 | } 238 | 239 | async function handleLnurlPayRequest1Forwarding( 240 | lightning: Client, 241 | user: IUserDB, 242 | requestedUsername: string, 243 | response: FastifyReply, 244 | ) { 245 | const currentRequest = lnurlPayForwardingRequestCounter++; 246 | lnurlPayForwardingRequests.set(currentRequest, { 247 | pubkey: user.pubkey, 248 | response, 249 | alias: requestedUsername, 250 | }); 251 | 252 | const request: ILnurlPayForwardP2PMessage = { 253 | id: currentRequest, 254 | request: "LNURLPAY_REQUEST1", 255 | data: null, 256 | metadata: { 257 | lightningAddress: `${requestedUsername}@${config.domain}`, 258 | }, 259 | }; 260 | 261 | await sendCustomMessage( 262 | lightning, 263 | user.pubkey, 264 | LnurlPayRequestLNP2PType, 265 | JSON.stringify(request), 266 | ); 267 | 268 | // Timeout after 30 seconds 269 | setTimeout(() => { 270 | if (lnurlPayForwardingRequests.has(currentRequest)) { 271 | lnurlPayForwardingRequests.delete(currentRequest); 272 | } 273 | response.send({ 274 | status: "ERROR", 275 | reason: `It's not possible to pay ${requestedUsername}@${config.domain} at this time.`, 276 | }); 277 | }, 30 * 1000); 278 | } 279 | 280 | async function handleLnurlPayRequest2Forwarding( 281 | lightning: Client, 282 | user: IUserDB, 283 | requestedUsername: string, 284 | amount: number, 285 | comment: string | undefined, 286 | payerData: string | undefined, 287 | response: FastifyReply, 288 | ) { 289 | const currentRequest = lnurlPayForwardingRequestCounter++; 290 | lnurlPayForwardingRequests.set(currentRequest, { 291 | pubkey: user.pubkey, 292 | response, 293 | alias: requestedUsername, 294 | }); 295 | 296 | const request: ILnurlPayForwardP2PMessage = { 297 | id: currentRequest, 298 | request: "LNURLPAY_REQUEST2", 299 | data: { 300 | amount, 301 | comment, 302 | payerdata: payerData, 303 | }, 304 | metadata: { 305 | lightningAddress: `${requestedUsername}@${config.domain}`, 306 | }, 307 | }; 308 | 309 | await sendCustomMessage( 310 | lightning, 311 | user.pubkey, 312 | LnurlPayRequestLNP2PType, 313 | JSON.stringify(request), 314 | ); 315 | 316 | // Timeout after 30 seconds 317 | setTimeout(() => { 318 | if (lnurlPayForwardingRequests.has(currentRequest)) { 319 | lnurlPayForwardingRequests.delete(currentRequest); 320 | } 321 | response.send({ 322 | status: "ERROR", 323 | reason: `It's not possible to pay ${requestedUsername}@${config.domain} at this time.`, 324 | }); 325 | }, 30 * 1000); 326 | } 327 | -------------------------------------------------------------------------------- /proto/router.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "rpc.proto"; 4 | 5 | package routerrpc; 6 | 7 | option go_package = "github.com/lightningnetwork/lnd/lnrpc/routerrpc"; 8 | 9 | // Router is a service that offers advanced interaction with the router 10 | // subsystem of the daemon. 11 | service Router { 12 | /* 13 | SendPaymentV2 attempts to route a payment described by the passed 14 | PaymentRequest to the final destination. The call returns a stream of 15 | payment updates. 16 | */ 17 | rpc SendPaymentV2 (SendPaymentRequest) returns (stream lnrpc.Payment); 18 | 19 | /* 20 | TrackPaymentV2 returns an update stream for the payment identified by the 21 | payment hash. 22 | */ 23 | rpc TrackPaymentV2 (TrackPaymentRequest) returns (stream lnrpc.Payment); 24 | 25 | /* 26 | EstimateRouteFee allows callers to obtain a lower bound w.r.t how much it 27 | may cost to send an HTLC to the target end destination. 28 | */ 29 | rpc EstimateRouteFee (RouteFeeRequest) returns (RouteFeeResponse); 30 | 31 | /* 32 | Deprecated, use SendToRouteV2. SendToRoute attempts to make a payment via 33 | the specified route. This method differs from SendPayment in that it 34 | allows users to specify a full route manually. This can be used for 35 | things like rebalancing, and atomic swaps. It differs from the newer 36 | SendToRouteV2 in that it doesn't return the full HTLC information. 37 | */ 38 | rpc SendToRoute (SendToRouteRequest) returns (SendToRouteResponse) { 39 | option deprecated = true; 40 | } 41 | 42 | /* 43 | SendToRouteV2 attempts to make a payment via the specified route. This 44 | method differs from SendPayment in that it allows users to specify a full 45 | route manually. This can be used for things like rebalancing, and atomic 46 | swaps. 47 | */ 48 | rpc SendToRouteV2 (SendToRouteRequest) returns (lnrpc.HTLCAttempt); 49 | 50 | /* 51 | ResetMissionControl clears all mission control state and starts with a clean 52 | slate. 53 | */ 54 | rpc ResetMissionControl (ResetMissionControlRequest) 55 | returns (ResetMissionControlResponse); 56 | 57 | /* 58 | QueryMissionControl exposes the internal mission control state to callers. 59 | It is a development feature. 60 | */ 61 | rpc QueryMissionControl (QueryMissionControlRequest) 62 | returns (QueryMissionControlResponse); 63 | 64 | /* 65 | XImportMissionControl is an experimental API that imports the state provided 66 | to the internal mission control's state, using all results which are more 67 | recent than our existing values. These values will only be imported 68 | in-memory, and will not be persisted across restarts. 69 | */ 70 | rpc XImportMissionControl (XImportMissionControlRequest) 71 | returns (XImportMissionControlResponse); 72 | 73 | /* 74 | GetMissionControlConfig returns mission control's current config. 75 | */ 76 | rpc GetMissionControlConfig (GetMissionControlConfigRequest) 77 | returns (GetMissionControlConfigResponse); 78 | 79 | /* 80 | SetMissionControlConfig will set mission control's config, if the config 81 | provided is valid. 82 | */ 83 | rpc SetMissionControlConfig (SetMissionControlConfigRequest) 84 | returns (SetMissionControlConfigResponse); 85 | 86 | /* 87 | QueryProbability returns the current success probability estimate for a 88 | given node pair and amount. 89 | */ 90 | rpc QueryProbability (QueryProbabilityRequest) 91 | returns (QueryProbabilityResponse); 92 | 93 | /* 94 | BuildRoute builds a fully specified route based on a list of hop public 95 | keys. It retrieves the relevant channel policies from the graph in order to 96 | calculate the correct fees and time locks. 97 | */ 98 | rpc BuildRoute (BuildRouteRequest) returns (BuildRouteResponse); 99 | 100 | /* 101 | SubscribeHtlcEvents creates a uni-directional stream from the server to 102 | the client which delivers a stream of htlc events. 103 | */ 104 | rpc SubscribeHtlcEvents (SubscribeHtlcEventsRequest) 105 | returns (stream HtlcEvent); 106 | 107 | /* 108 | Deprecated, use SendPaymentV2. SendPayment attempts to route a payment 109 | described by the passed PaymentRequest to the final destination. The call 110 | returns a stream of payment status updates. 111 | */ 112 | rpc SendPayment (SendPaymentRequest) returns (stream PaymentStatus) { 113 | option deprecated = true; 114 | } 115 | 116 | /* 117 | Deprecated, use TrackPaymentV2. TrackPayment returns an update stream for 118 | the payment identified by the payment hash. 119 | */ 120 | rpc TrackPayment (TrackPaymentRequest) returns (stream PaymentStatus) { 121 | option deprecated = true; 122 | } 123 | 124 | /** 125 | HtlcInterceptor dispatches a bi-directional streaming RPC in which 126 | Forwarded HTLC requests are sent to the client and the client responds with 127 | a boolean that tells LND if this htlc should be intercepted. 128 | In case of interception, the htlc can be either settled, cancelled or 129 | resumed later by using the ResolveHoldForward endpoint. 130 | */ 131 | rpc HtlcInterceptor (stream ForwardHtlcInterceptResponse) 132 | returns (stream ForwardHtlcInterceptRequest); 133 | 134 | /* 135 | UpdateChanStatus attempts to manually set the state of a channel 136 | (enabled, disabled, or auto). A manual "disable" request will cause the 137 | channel to stay disabled until a subsequent manual request of either 138 | "enable" or "auto". 139 | */ 140 | rpc UpdateChanStatus (UpdateChanStatusRequest) 141 | returns (UpdateChanStatusResponse); 142 | } 143 | 144 | message SendPaymentRequest { 145 | // The identity pubkey of the payment recipient 146 | bytes dest = 1; 147 | 148 | /* 149 | Number of satoshis to send. 150 | 151 | The fields amt and amt_msat are mutually exclusive. 152 | */ 153 | int64 amt = 2; 154 | 155 | /* 156 | Number of millisatoshis to send. 157 | 158 | The fields amt and amt_msat are mutually exclusive. 159 | */ 160 | int64 amt_msat = 12; 161 | 162 | // The hash to use within the payment's HTLC 163 | bytes payment_hash = 3; 164 | 165 | /* 166 | The CLTV delta from the current height that should be used to set the 167 | timelock for the final hop. 168 | */ 169 | int32 final_cltv_delta = 4; 170 | 171 | // An optional payment addr to be included within the last hop of the route. 172 | bytes payment_addr = 20; 173 | 174 | /* 175 | A bare-bones invoice for a payment within the Lightning Network. With the 176 | details of the invoice, the sender has all the data necessary to send a 177 | payment to the recipient. The amount in the payment request may be zero. In 178 | that case it is required to set the amt field as well. If no payment request 179 | is specified, the following fields are required: dest, amt and payment_hash. 180 | */ 181 | string payment_request = 5; 182 | 183 | /* 184 | An upper limit on the amount of time we should spend when attempting to 185 | fulfill the payment. This is expressed in seconds. If we cannot make a 186 | successful payment within this time frame, an error will be returned. 187 | This field must be non-zero. 188 | */ 189 | int32 timeout_seconds = 6; 190 | 191 | /* 192 | The maximum number of satoshis that will be paid as a fee of the payment. 193 | If this field is left to the default value of 0, only zero-fee routes will 194 | be considered. This usually means single hop routes connecting directly to 195 | the destination. To send the payment without a fee limit, use max int here. 196 | 197 | The fields fee_limit_sat and fee_limit_msat are mutually exclusive. 198 | */ 199 | int64 fee_limit_sat = 7; 200 | 201 | /* 202 | The maximum number of millisatoshis that will be paid as a fee of the 203 | payment. If this field is left to the default value of 0, only zero-fee 204 | routes will be considered. This usually means single hop routes connecting 205 | directly to the destination. To send the payment without a fee limit, use 206 | max int here. 207 | 208 | The fields fee_limit_sat and fee_limit_msat are mutually exclusive. 209 | */ 210 | int64 fee_limit_msat = 13; 211 | 212 | /* 213 | Deprecated, use outgoing_chan_ids. The channel id of the channel that must 214 | be taken to the first hop. If zero, any channel may be used (unless 215 | outgoing_chan_ids are set). 216 | */ 217 | uint64 outgoing_chan_id = 8 [jstype = JS_STRING, deprecated = true]; 218 | 219 | /* 220 | The channel ids of the channels are allowed for the first hop. If empty, 221 | any channel may be used. 222 | */ 223 | repeated uint64 outgoing_chan_ids = 19; 224 | 225 | /* 226 | The pubkey of the last hop of the route. If empty, any hop may be used. 227 | */ 228 | bytes last_hop_pubkey = 14; 229 | 230 | /* 231 | An optional maximum total time lock for the route. This should not exceed 232 | lnd's `--max-cltv-expiry` setting. If zero, then the value of 233 | `--max-cltv-expiry` is enforced. 234 | */ 235 | int32 cltv_limit = 9; 236 | 237 | /* 238 | Optional route hints to reach the destination through private channels. 239 | */ 240 | repeated lnrpc.RouteHint route_hints = 10; 241 | 242 | /* 243 | An optional field that can be used to pass an arbitrary set of TLV records 244 | to a peer which understands the new records. This can be used to pass 245 | application specific data during the payment attempt. Record types are 246 | required to be in the custom range >= 65536. When using REST, the values 247 | must be encoded as base64. 248 | */ 249 | map dest_custom_records = 11; 250 | 251 | // If set, circular payments to self are permitted. 252 | bool allow_self_payment = 15; 253 | 254 | /* 255 | Features assumed to be supported by the final node. All transitive feature 256 | dependencies must also be set properly. For a given feature bit pair, either 257 | optional or remote may be set, but not both. If this field is nil or empty, 258 | the router will try to load destination features from the graph as a 259 | fallback. 260 | */ 261 | repeated lnrpc.FeatureBit dest_features = 16; 262 | 263 | /* 264 | The maximum number of partial payments that may be use to complete the full 265 | amount. 266 | */ 267 | uint32 max_parts = 17; 268 | 269 | /* 270 | If set, only the final payment update is streamed back. Intermediate updates 271 | that show which htlcs are still in flight are suppressed. 272 | */ 273 | bool no_inflight_updates = 18; 274 | 275 | /* 276 | The largest payment split that should be attempted when making a payment if 277 | splitting is necessary. Setting this value will effectively cause lnd to 278 | split more aggressively, vs only when it thinks it needs to. Note that this 279 | value is in milli-satoshis. 280 | */ 281 | uint64 max_shard_size_msat = 21; 282 | } 283 | 284 | message TrackPaymentRequest { 285 | // The hash of the payment to look up. 286 | bytes payment_hash = 1; 287 | 288 | /* 289 | If set, only the final payment update is streamed back. Intermediate updates 290 | that show which htlcs are still in flight are suppressed. 291 | */ 292 | bool no_inflight_updates = 2; 293 | } 294 | 295 | message RouteFeeRequest { 296 | /* 297 | The destination once wishes to obtain a routing fee quote to. 298 | */ 299 | bytes dest = 1; 300 | 301 | /* 302 | The amount one wishes to send to the target destination. 303 | */ 304 | int64 amt_sat = 2; 305 | } 306 | 307 | message RouteFeeResponse { 308 | /* 309 | A lower bound of the estimated fee to the target destination within the 310 | network, expressed in milli-satoshis. 311 | */ 312 | int64 routing_fee_msat = 1; 313 | 314 | /* 315 | An estimate of the worst case time delay that can occur. Note that callers 316 | will still need to factor in the final CLTV delta of the last hop into this 317 | value. 318 | */ 319 | int64 time_lock_delay = 2; 320 | } 321 | 322 | message SendToRouteRequest { 323 | // The payment hash to use for the HTLC. 324 | bytes payment_hash = 1; 325 | 326 | // Route that should be used to attempt to complete the payment. 327 | lnrpc.Route route = 2; 328 | } 329 | 330 | message SendToRouteResponse { 331 | // The preimage obtained by making the payment. 332 | bytes preimage = 1; 333 | 334 | // The failure message in case the payment failed. 335 | lnrpc.Failure failure = 2; 336 | } 337 | 338 | message ResetMissionControlRequest { 339 | } 340 | 341 | message ResetMissionControlResponse { 342 | } 343 | 344 | message QueryMissionControlRequest { 345 | } 346 | 347 | // QueryMissionControlResponse contains mission control state. 348 | message QueryMissionControlResponse { 349 | reserved 1; 350 | 351 | // Node pair-level mission control state. 352 | repeated PairHistory pairs = 2; 353 | } 354 | 355 | message XImportMissionControlRequest { 356 | // Node pair-level mission control state to be imported. 357 | repeated PairHistory pairs = 1; 358 | } 359 | 360 | message XImportMissionControlResponse { 361 | } 362 | 363 | // PairHistory contains the mission control state for a particular node pair. 364 | message PairHistory { 365 | // The source node pubkey of the pair. 366 | bytes node_from = 1; 367 | 368 | // The destination node pubkey of the pair. 369 | bytes node_to = 2; 370 | 371 | reserved 3, 4, 5, 6; 372 | 373 | PairData history = 7; 374 | } 375 | 376 | message PairData { 377 | // Time of last failure. 378 | int64 fail_time = 1; 379 | 380 | /* 381 | Lowest amount that failed to forward rounded to whole sats. This may be 382 | set to zero if the failure is independent of amount. 383 | */ 384 | int64 fail_amt_sat = 2; 385 | 386 | /* 387 | Lowest amount that failed to forward in millisats. This may be 388 | set to zero if the failure is independent of amount. 389 | */ 390 | int64 fail_amt_msat = 4; 391 | 392 | reserved 3; 393 | 394 | // Time of last success. 395 | int64 success_time = 5; 396 | 397 | // Highest amount that we could successfully forward rounded to whole sats. 398 | int64 success_amt_sat = 6; 399 | 400 | // Highest amount that we could successfully forward in millisats. 401 | int64 success_amt_msat = 7; 402 | } 403 | 404 | message GetMissionControlConfigRequest { 405 | } 406 | 407 | message GetMissionControlConfigResponse { 408 | /* 409 | Mission control's currently active config. 410 | */ 411 | MissionControlConfig config = 1; 412 | } 413 | 414 | message SetMissionControlConfigRequest { 415 | /* 416 | The config to set for mission control. Note that all values *must* be set, 417 | because the full config will be applied. 418 | */ 419 | MissionControlConfig config = 1; 420 | } 421 | 422 | message SetMissionControlConfigResponse { 423 | } 424 | 425 | message MissionControlConfig { 426 | /* 427 | The amount of time mission control will take to restore a penalized node 428 | or channel back to 50% success probability, expressed in seconds. Setting 429 | this value to a higher value will penalize failures for longer, making 430 | mission control less likely to route through nodes and channels that we 431 | have previously recorded failures for. 432 | */ 433 | uint64 half_life_seconds = 1; 434 | 435 | /* 436 | The probability of success mission control should assign to hop in a route 437 | where it has no other information available. Higher values will make mission 438 | control more willing to try hops that we have no information about, lower 439 | values will discourage trying these hops. 440 | */ 441 | float hop_probability = 2; 442 | 443 | /* 444 | The importance that mission control should place on historical results, 445 | expressed as a value in [0;1]. Setting this value to 1 will ignore all 446 | historical payments and just use the hop probability to assess the 447 | probability of success for each hop. A zero value ignores hop probability 448 | completely and relies entirely on historical results, unless none are 449 | available. 450 | */ 451 | float weight = 3; 452 | 453 | /* 454 | The maximum number of payment results that mission control will store. 455 | */ 456 | uint32 maximum_payment_results = 4; 457 | 458 | /* 459 | The minimum time that must have passed since the previously recorded failure 460 | before we raise the failure amount. 461 | */ 462 | uint64 minimum_failure_relax_interval = 5; 463 | } 464 | 465 | message QueryProbabilityRequest { 466 | // The source node pubkey of the pair. 467 | bytes from_node = 1; 468 | 469 | // The destination node pubkey of the pair. 470 | bytes to_node = 2; 471 | 472 | // The amount for which to calculate a probability. 473 | int64 amt_msat = 3; 474 | } 475 | 476 | message QueryProbabilityResponse { 477 | // The success probability for the requested pair. 478 | double probability = 1; 479 | 480 | // The historical data for the requested pair. 481 | PairData history = 2; 482 | } 483 | 484 | message BuildRouteRequest { 485 | /* 486 | The amount to send expressed in msat. If set to zero, the minimum routable 487 | amount is used. 488 | */ 489 | int64 amt_msat = 1; 490 | 491 | /* 492 | CLTV delta from the current height that should be used for the timelock 493 | of the final hop 494 | */ 495 | int32 final_cltv_delta = 2; 496 | 497 | /* 498 | The channel id of the channel that must be taken to the first hop. If zero, 499 | any channel may be used. 500 | */ 501 | uint64 outgoing_chan_id = 3 [jstype = JS_STRING]; 502 | 503 | /* 504 | A list of hops that defines the route. This does not include the source hop 505 | pubkey. 506 | */ 507 | repeated bytes hop_pubkeys = 4; 508 | 509 | // An optional payment addr to be included within the last hop of the route. 510 | bytes payment_addr = 5; 511 | } 512 | 513 | message BuildRouteResponse { 514 | /* 515 | Fully specified route that can be used to execute the payment. 516 | */ 517 | lnrpc.Route route = 1; 518 | } 519 | 520 | message SubscribeHtlcEventsRequest { 521 | } 522 | 523 | /* 524 | HtlcEvent contains the htlc event that was processed. These are served on a 525 | best-effort basis; events are not persisted, delivery is not guaranteed 526 | (in the event of a crash in the switch, forward events may be lost) and 527 | some events may be replayed upon restart. Events consumed from this package 528 | should be de-duplicated by the htlc's unique combination of incoming and 529 | outgoing channel id and htlc id. [EXPERIMENTAL] 530 | */ 531 | message HtlcEvent { 532 | /* 533 | The short channel id that the incoming htlc arrived at our node on. This 534 | value is zero for sends. 535 | */ 536 | uint64 incoming_channel_id = 1; 537 | 538 | /* 539 | The short channel id that the outgoing htlc left our node on. This value 540 | is zero for receives. 541 | */ 542 | uint64 outgoing_channel_id = 2; 543 | 544 | /* 545 | Incoming id is the index of the incoming htlc in the incoming channel. 546 | This value is zero for sends. 547 | */ 548 | uint64 incoming_htlc_id = 3; 549 | 550 | /* 551 | Outgoing id is the index of the outgoing htlc in the outgoing channel. 552 | This value is zero for receives. 553 | */ 554 | uint64 outgoing_htlc_id = 4; 555 | 556 | /* 557 | The time in unix nanoseconds that the event occurred. 558 | */ 559 | uint64 timestamp_ns = 5; 560 | 561 | enum EventType { 562 | UNKNOWN = 0; 563 | SEND = 1; 564 | RECEIVE = 2; 565 | FORWARD = 3; 566 | } 567 | 568 | /* 569 | The event type indicates whether the htlc was part of a send, receive or 570 | forward. 571 | */ 572 | EventType event_type = 6; 573 | 574 | oneof event { 575 | ForwardEvent forward_event = 7; 576 | ForwardFailEvent forward_fail_event = 8; 577 | SettleEvent settle_event = 9; 578 | LinkFailEvent link_fail_event = 10; 579 | } 580 | } 581 | 582 | message HtlcInfo { 583 | // The timelock on the incoming htlc. 584 | uint32 incoming_timelock = 1; 585 | 586 | // The timelock on the outgoing htlc. 587 | uint32 outgoing_timelock = 2; 588 | 589 | // The amount of the incoming htlc. 590 | uint64 incoming_amt_msat = 3; 591 | 592 | // The amount of the outgoing htlc. 593 | uint64 outgoing_amt_msat = 4; 594 | } 595 | 596 | message ForwardEvent { 597 | // Info contains details about the htlc that was forwarded. 598 | HtlcInfo info = 1; 599 | } 600 | 601 | message ForwardFailEvent { 602 | } 603 | 604 | message SettleEvent { 605 | } 606 | 607 | message LinkFailEvent { 608 | // Info contains details about the htlc that we failed. 609 | HtlcInfo info = 1; 610 | 611 | // FailureCode is the BOLT error code for the failure. 612 | lnrpc.Failure.FailureCode wire_failure = 2; 613 | 614 | /* 615 | FailureDetail provides additional information about the reason for the 616 | failure. This detail enriches the information provided by the wire message 617 | and may be 'no detail' if the wire message requires no additional metadata. 618 | */ 619 | FailureDetail failure_detail = 3; 620 | 621 | // A string representation of the link failure. 622 | string failure_string = 4; 623 | } 624 | 625 | enum FailureDetail { 626 | UNKNOWN = 0; 627 | NO_DETAIL = 1; 628 | ONION_DECODE = 2; 629 | LINK_NOT_ELIGIBLE = 3; 630 | ON_CHAIN_TIMEOUT = 4; 631 | HTLC_EXCEEDS_MAX = 5; 632 | INSUFFICIENT_BALANCE = 6; 633 | INCOMPLETE_FORWARD = 7; 634 | HTLC_ADD_FAILED = 8; 635 | FORWARDS_DISABLED = 9; 636 | INVOICE_CANCELED = 10; 637 | INVOICE_UNDERPAID = 11; 638 | INVOICE_EXPIRY_TOO_SOON = 12; 639 | INVOICE_NOT_OPEN = 13; 640 | MPP_INVOICE_TIMEOUT = 14; 641 | ADDRESS_MISMATCH = 15; 642 | SET_TOTAL_MISMATCH = 16; 643 | SET_TOTAL_TOO_LOW = 17; 644 | SET_OVERPAID = 18; 645 | UNKNOWN_INVOICE = 19; 646 | INVALID_KEYSEND = 20; 647 | MPP_IN_PROGRESS = 21; 648 | CIRCULAR_ROUTE = 22; 649 | } 650 | 651 | enum PaymentState { 652 | /* 653 | Payment is still in flight. 654 | */ 655 | IN_FLIGHT = 0; 656 | 657 | /* 658 | Payment completed successfully. 659 | */ 660 | SUCCEEDED = 1; 661 | 662 | /* 663 | There are more routes to try, but the payment timeout was exceeded. 664 | */ 665 | FAILED_TIMEOUT = 2; 666 | 667 | /* 668 | All possible routes were tried and failed permanently. Or were no 669 | routes to the destination at all. 670 | */ 671 | FAILED_NO_ROUTE = 3; 672 | 673 | /* 674 | A non-recoverable error has occured. 675 | */ 676 | FAILED_ERROR = 4; 677 | 678 | /* 679 | Payment details incorrect (unknown hash, invalid amt or 680 | invalid final cltv delta) 681 | */ 682 | FAILED_INCORRECT_PAYMENT_DETAILS = 5; 683 | 684 | /* 685 | Insufficient local balance. 686 | */ 687 | FAILED_INSUFFICIENT_BALANCE = 6; 688 | } 689 | 690 | message PaymentStatus { 691 | // Current state the payment is in. 692 | PaymentState state = 1; 693 | 694 | /* 695 | The pre-image of the payment when state is SUCCEEDED. 696 | */ 697 | bytes preimage = 2; 698 | 699 | reserved 3; 700 | 701 | /* 702 | The HTLCs made in attempt to settle the payment [EXPERIMENTAL]. 703 | */ 704 | repeated lnrpc.HTLCAttempt htlcs = 4; 705 | } 706 | 707 | message CircuitKey { 708 | /// The id of the channel that the is part of this circuit. 709 | uint64 chan_id = 1; 710 | 711 | /// The index of the incoming htlc in the incoming channel. 712 | uint64 htlc_id = 2; 713 | } 714 | 715 | message ForwardHtlcInterceptRequest { 716 | /* 717 | The key of this forwarded htlc. It defines the incoming channel id and 718 | the index in this channel. 719 | */ 720 | CircuitKey incoming_circuit_key = 1; 721 | 722 | // The incoming htlc amount. 723 | uint64 incoming_amount_msat = 5; 724 | 725 | // The incoming htlc expiry. 726 | uint32 incoming_expiry = 6; 727 | 728 | /* 729 | The htlc payment hash. This value is not guaranteed to be unique per 730 | request. 731 | */ 732 | bytes payment_hash = 2; 733 | 734 | // The requested outgoing channel id for this forwarded htlc. Because of 735 | // non-strict forwarding, this isn't necessarily the channel over which the 736 | // packet will be forwarded eventually. A different channel to the same peer 737 | // may be selected as well. 738 | uint64 outgoing_requested_chan_id = 7; 739 | 740 | // The outgoing htlc amount. 741 | uint64 outgoing_amount_msat = 3; 742 | 743 | // The outgoing htlc expiry. 744 | uint32 outgoing_expiry = 4; 745 | 746 | // Any custom records that were present in the payload. 747 | map custom_records = 8; 748 | 749 | // The onion blob for the next hop 750 | bytes onion_blob = 9; 751 | } 752 | 753 | /** 754 | ForwardHtlcInterceptResponse enables the caller to resolve a previously hold 755 | forward. The caller can choose either to: 756 | - `Resume`: Execute the default behavior (usually forward). 757 | - `Reject`: Fail the htlc backwards. 758 | - `Settle`: Settle this htlc with a given preimage. 759 | */ 760 | message ForwardHtlcInterceptResponse { 761 | /** 762 | The key of this forwarded htlc. It defines the incoming channel id and 763 | the index in this channel. 764 | */ 765 | CircuitKey incoming_circuit_key = 1; 766 | 767 | // The resolve action for this intercepted htlc. 768 | ResolveHoldForwardAction action = 2; 769 | 770 | // The preimage in case the resolve action is Settle. 771 | bytes preimage = 3; 772 | } 773 | 774 | enum ResolveHoldForwardAction { 775 | SETTLE = 0; 776 | FAIL = 1; 777 | RESUME = 2; 778 | } 779 | 780 | message UpdateChanStatusRequest { 781 | lnrpc.ChannelPoint chan_point = 1; 782 | 783 | ChanStatusAction action = 2; 784 | } 785 | 786 | enum ChanStatusAction { 787 | ENABLE = 0; 788 | DISABLE = 1; 789 | AUTO = 2; 790 | } 791 | 792 | message UpdateChanStatusResponse { 793 | } 794 | --------------------------------------------------------------------------------