diff --git a/Procfile b/Procfile index 9d641747..8f65bb0f 100644 --- a/Procfile +++ b/Procfile @@ -1,2 +1,2 @@ web: npm run start -walletd: node --trace-warnings walletd/index.js \ No newline at end of file +worker: node --trace-warnings worker/index.js \ No newline at end of file diff --git a/README.md b/README.md index becfdb40..8adddbf1 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,10 @@ You should then be able to access the site at `localhost:3000` and any changes you make will hot reload. If you want to login locally or use lnd you'll need to modify `.env.sample` appropriately. If you have trouble please open an issue so I can help and update the README for everyone else. # stack -The site is written in javascript using Next.js, a React framework. The backend API is provided via graphql. The database is postgresql modelled with prisma. We use lnd for the lightning node which we connect to through a tor http tunnel. A customized Bootstrap theme is used for styling. +The site is written in javascript using Next.js, a React framework. The backend API is provided via graphql. The database is postgresql modelled with prisma. The job queue is also maintained in postgresql. We use lnd for the lightning node which we connect to through a tor http tunnel. A customized Bootstrap theme is used for styling. # processes -There are two. 1. the web app and 2. walletd, which checks and polls lnd for all pending invoice/withdrawal statuses in case the web process dies. +There are two. 1. the web app and 2. the worker, which dequeues jobs sent to it by the web app, e.g. polling lnd for invoice/payment status # wallet transaction safety To ensure user balances are kept sane, all wallet updates are run in serializable transactions at the database level. Because prisma has relatively poor support for transactions all wallet touching code is written in plpgsql stored procedures and can be found in the prisma/migrations folder. diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 95467dc6..0ff7d064 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -1,4 +1,4 @@ -import { createInvoice, decodePaymentRequest, subscribeToPayViaRequest } from 'ln-service' +import { createInvoice, decodePaymentRequest, payViaPaymentRequest } from 'ln-service' import { UserInputError, AuthenticationError } from 'apollo-server-micro' import serialize from './serial' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' @@ -179,19 +179,11 @@ export default { expires_at: expiresAt }) - const data = { - hash: invoice.id, - bolt11: invoice.request, - expiresAt: expiresAt, - msatsRequested: amount * 1000, - user: { - connect: { - id: me.id - } - } - } + const [inv] = await serialize(models, + models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request}, + ${expiresAt}, ${amount * 1000}, ${me.id})`) - return await models.invoice.create({ data }) + return inv } catch (error) { console.log(error) throw error @@ -206,7 +198,6 @@ export default { throw new UserInputError('could not decode invoice') } - // TODO: test if (!decoded.mtokens || Number(decoded.mtokens) <= 0) { throw new UserInputError('you must specify amount') } @@ -218,8 +209,7 @@ export default { models.$queryRaw`SELECT * FROM create_withdrawl(${decoded.id}, ${invoice}, ${Number(decoded.mtokens)}, ${msatsFee}, ${me.name})`) - // create the payment, subscribing to its status - const sub = subscribeToPayViaRequest({ + payViaPaymentRequest({ lnd, request: invoice, // can't use max_fee_mtokens https://github.com/alexbosworth/ln-service/issues/141 @@ -227,41 +217,6 @@ export default { pathfinding_timeout: 30000 }) - // if it's confirmed, update confirmed returning extra fees to user - sub.once('confirmed', async e => { - console.log(e) - - sub.removeAllListeners() - - // mtokens also contains the fee - const fee = Number(e.fee_mtokens) - const paid = Number(e.mtokens) - fee - await serialize(models, models.$queryRaw` - SELECT confirm_withdrawl(${withdrawl.id}, ${paid}, ${fee})`) - }) - - // if the payment fails, we need to - // 1. return the funds to the user - // 2. update the widthdrawl as failed - sub.once('failed', async e => { - console.log(e) - - sub.removeAllListeners() - - let status = 'UNKNOWN_FAILURE' - if (e.is_insufficient_balance) { - status = 'INSUFFICIENT_BALANCE' - } else if (e.is_invalid_payment) { - status = 'INVALID_PAYMENT' - } else if (e.is_pathfinding_timeout) { - status = 'PATHFINDING_TIMEOUT' - } else if (e.is_route_not_found) { - status = 'ROUTE_NOT_FOUND' - } - await serialize(models, models.$queryRaw` - SELECT reverse_withdrawl(${withdrawl.id}, ${status})`) - }) - return withdrawl } }, diff --git a/components/header.js b/components/header.js index f3335285..56095414 100644 --- a/components/header.js +++ b/components/header.js @@ -119,8 +119,8 @@ export default function Header () { const strike = useLightning() useEffect(() => { setTimeout(strike, randInRange(3000, 10000)) + setFired(true) }, [router.asPath]) - setFired(true) } return path !== '/login' && !path.startsWith('/invites') && } diff --git a/package-lock.json b/package-lock.json index 647ba6e7..b82029b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "nextjs-progressbar": "^0.0.13", "page-metadata-parser": "^1.1.4", "pageres": "^6.2.3", + "pg-boss": "^7.0.2", "prisma": "^2.25.0", "qrcode.react": "^1.0.1", "react": "^17.0.1", @@ -2353,6 +2354,14 @@ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" }, + "node_modules/buffer-writer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", + "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==", + "engines": { + "node": ">=4" + } + }, "node_modules/buffer-xor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", @@ -2937,6 +2946,17 @@ "sha.js": "^2.4.8" } }, + "node_modules/cron-parser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.2.1.tgz", + "integrity": "sha512-5sJBwDYyCp+0vU5b7POl8zLWfgV5fOHxlc45FWoWdHecGC7MQHCjx0CHivCMRnGFovghKhhyYM+Zm9DcY5qcHg==", + "dependencies": { + "luxon": "^1.28.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -3151,6 +3171,17 @@ "node": ">= 0.4" } }, + "node_modules/delay": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", + "integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -5837,6 +5868,11 @@ "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", "dev": true }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -6072,6 +6108,14 @@ "node": ">=10" } }, + "node_modules/luxon": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.28.0.tgz", + "integrity": "sha512-TfTiyvZhwBYM/7QdAVDh+7dBTBA29v4ik0Ce9zda3Mnf8on1S5KJI8P2jKFZ8+5C0jhmr0KwJEO/Wdpm0VeWJQ==", + "engines": { + "node": "*" + } + }, "node_modules/macaroon": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/macaroon/-/macaroon-3.0.4.tgz", @@ -7488,6 +7532,11 @@ "node": ">=6" } }, + "node_modules/packet-reader": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz", + "integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==" + }, "node_modules/page-metadata-parser": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/page-metadata-parser/-/page-metadata-parser-1.1.4.tgz", @@ -7685,6 +7734,96 @@ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=" }, + "node_modules/pg": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.7.1.tgz", + "integrity": "sha512-7bdYcv7V6U3KAtWjpQJJBww0UEsWuh4yQ/EjNf2HeO/NnvKjpvhEIe/A/TleP6wtmSKnUnghs5A9jUoK6iDdkA==", + "dependencies": { + "buffer-writer": "2.0.0", + "packet-reader": "1.0.0", + "pg-connection-string": "^2.5.0", + "pg-pool": "^3.4.1", + "pg-protocol": "^1.5.0", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "pg-native": ">=2.0.0" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-boss": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/pg-boss/-/pg-boss-7.0.2.tgz", + "integrity": "sha512-5s4HsrkGd8qbNYPf+SBxLZ3gCZYdNbttUCRpyuH6aCz6niR4Macoieuwv3JBwvHdNqDWoLtx6o5wMgFCjt/oZQ==", + "dependencies": { + "cron-parser": "^4.0.0", + "delay": "^5.0.0", + "lodash.debounce": "^4.0.8", + "p-map": "^4.0.0", + "pg": "^8.5.1", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pg-connection-string": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz", + "integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.4.1.tgz", + "integrity": "sha512-TVHxR/gf3MeJRvchgNHxsYsTCHQ+4wm3VIHSS19z8NC0+gioEhq1okDY1sm/TYbfoP6JLFx01s0ShvZ3puP/iQ==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.5.0.tgz", + "integrity": "sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picomatch": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", @@ -8051,6 +8190,41 @@ "node": ">=0.10.0" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha1-AntTPAqokOJtFy1Hz5zOzFIazTU=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/preact": { "version": "10.5.14", "resolved": "https://registry.npmjs.org/preact/-/preact-10.5.14.tgz", @@ -9239,6 +9413,14 @@ "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.10.tgz", "integrity": "sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==" }, + "node_modules/split2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz", + "integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -13492,6 +13674,11 @@ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" }, + "buffer-writer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", + "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==" + }, "buffer-xor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", @@ -13938,6 +14125,14 @@ "sha.js": "^2.4.8" } }, + "cron-parser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.2.1.tgz", + "integrity": "sha512-5sJBwDYyCp+0vU5b7POl8zLWfgV5fOHxlc45FWoWdHecGC7MQHCjx0CHivCMRnGFovghKhhyYM+Zm9DcY5qcHg==", + "requires": { + "luxon": "^1.28.0" + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -14089,6 +14284,11 @@ "object-keys": "^1.0.12" } }, + "delay": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", + "integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==" + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -16090,6 +16290,11 @@ "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", "dev": true }, + "lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=" + }, "lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -16273,6 +16478,11 @@ "yallist": "^4.0.0" } }, + "luxon": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.28.0.tgz", + "integrity": "sha512-TfTiyvZhwBYM/7QdAVDh+7dBTBA29v4ik0Ce9zda3Mnf8on1S5KJI8P2jKFZ8+5C0jhmr0KwJEO/Wdpm0VeWJQ==" + }, "macaroon": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/macaroon/-/macaroon-3.0.4.tgz", @@ -17342,6 +17552,11 @@ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" }, + "packet-reader": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz", + "integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==" + }, "page-metadata-parser": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/page-metadata-parser/-/page-metadata-parser-1.1.4.tgz", @@ -17504,6 +17719,74 @@ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=" }, + "pg": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.7.1.tgz", + "integrity": "sha512-7bdYcv7V6U3KAtWjpQJJBww0UEsWuh4yQ/EjNf2HeO/NnvKjpvhEIe/A/TleP6wtmSKnUnghs5A9jUoK6iDdkA==", + "requires": { + "buffer-writer": "2.0.0", + "packet-reader": "1.0.0", + "pg-connection-string": "^2.5.0", + "pg-pool": "^3.4.1", + "pg-protocol": "^1.5.0", + "pg-types": "^2.1.0", + "pgpass": "1.x" + } + }, + "pg-boss": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/pg-boss/-/pg-boss-7.0.2.tgz", + "integrity": "sha512-5s4HsrkGd8qbNYPf+SBxLZ3gCZYdNbttUCRpyuH6aCz6niR4Macoieuwv3JBwvHdNqDWoLtx6o5wMgFCjt/oZQ==", + "requires": { + "cron-parser": "^4.0.0", + "delay": "^5.0.0", + "lodash.debounce": "^4.0.8", + "p-map": "^4.0.0", + "pg": "^8.5.1", + "uuid": "^8.3.2" + } + }, + "pg-connection-string": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz", + "integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==" + }, + "pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" + }, + "pg-pool": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.4.1.tgz", + "integrity": "sha512-TVHxR/gf3MeJRvchgNHxsYsTCHQ+4wm3VIHSS19z8NC0+gioEhq1okDY1sm/TYbfoP6JLFx01s0ShvZ3puP/iQ==", + "requires": {} + }, + "pg-protocol": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.5.0.tgz", + "integrity": "sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==" + }, + "pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "requires": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + } + }, + "pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "requires": { + "split2": "^4.1.0" + } + }, "picomatch": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", @@ -17769,6 +18052,29 @@ } } }, + "postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" + }, + "postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha1-AntTPAqokOJtFy1Hz5zOzFIazTU=" + }, + "postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==" + }, + "postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "requires": { + "xtend": "^4.0.0" + } + }, "preact": { "version": "10.5.14", "resolved": "https://registry.npmjs.org/preact/-/preact-10.5.14.tgz", @@ -18708,6 +19014,11 @@ "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.10.tgz", "integrity": "sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==" }, + "split2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz", + "integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==" + }, "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", diff --git a/package.json b/package.json index ec2284c8..9b0f23f0 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "nextjs-progressbar": "^0.0.13", "page-metadata-parser": "^1.1.4", "pageres": "^6.2.3", + "pg-boss": "^7.0.2", "prisma": "^2.25.0", "qrcode.react": "^1.0.1", "react": "^17.0.1", @@ -70,4 +71,4 @@ "eslint-plugin-compat": "^3.9.0", "standard": "^16.0.3" } -} +} \ No newline at end of file diff --git a/pages/withdrawals/[id].js b/pages/withdrawals/[id].js index 8171609e..ab8ed855 100644 --- a/pages/withdrawals/[id].js +++ b/pages/withdrawals/[id].js @@ -62,6 +62,10 @@ function LoadWithdrawl () { status = <>no route try increasing max fee variant = 'failed' break + case 'UNKNOWN_FAILURE': + status = <>unknown error + variant = 'failed' + break default: break } diff --git a/prisma/migrations/20220104214008_wallet_funcs/migration.sql b/prisma/migrations/20220104214008_wallet_funcs/migration.sql new file mode 100644 index 00000000..fd378bbc --- /dev/null +++ b/prisma/migrations/20220104214008_wallet_funcs/migration.sql @@ -0,0 +1,56 @@ +-- This is an empty migration. + +CREATE OR REPLACE FUNCTION create_withdrawl(lnd_id TEXT, invoice TEXT, msats_amount INTEGER, msats_max_fee INTEGER, username TEXT) +RETURNS "Withdrawl" +LANGUAGE plpgsql +AS $$ +DECLARE + user_id INTEGER; + user_msats INTEGER; + withdrawl "Withdrawl"; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT msats, id INTO user_msats, user_id FROM users WHERE name = username; + IF (msats_amount + msats_max_fee) > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + END IF; + + IF EXISTS (SELECT 1 FROM "Withdrawl" WHERE hash = lnd_id AND status IS NULL) THEN + RAISE EXCEPTION 'SN_PENDING_WITHDRAWL_EXISTS'; + END IF; + + IF EXISTS (SELECT 1 FROM "Withdrawl" WHERE hash = lnd_id AND status = 'CONFIRMED') THEN + RAISE EXCEPTION 'SN_CONFIRMED_WITHDRAWL_EXISTS'; + END IF; + + INSERT INTO "Withdrawl" (hash, bolt11, "msatsPaying", "msatsFeePaying", "userId", created_at, updated_at) + VALUES (lnd_id, invoice, msats_amount, msats_max_fee, user_id, now_utc(), now_utc()) RETURNING * INTO withdrawl; + + UPDATE users SET msats = msats - msats_amount - msats_max_fee WHERE id = user_id; + + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) + VALUES ('checkWithdrawal', jsonb_build_object('id', withdrawl.id, 'hash', lnd_id), 21, true, now() + interval '10 seconds'); + + RETURN withdrawl; +END; +$$; + +CREATE OR REPLACE FUNCTION create_invoice(hash TEXT, bolt11 TEXT, expires_at timestamp(3) without time zone, msats_req INTEGER, user_id INTEGER) +RETURNS "Invoice" +LANGUAGE plpgsql +AS $$ +DECLARE + invoice "Invoice"; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + INSERT INTO "Invoice" (hash, bolt11, "expiresAt", "msatsRequested", "userId", created_at, updated_at) + VALUES (hash, bolt11, expires_at, msats_req, user_id, now_utc(), now_utc()) RETURNING * INTO invoice; + + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) + VALUES ('checkInvoice', jsonb_build_object('hash', hash), 21, true, now() + interval '10 seconds'); + + RETURN invoice; +END; +$$; \ No newline at end of file diff --git a/walletd/index.js b/walletd/index.js deleted file mode 100644 index 49c6e6de..00000000 --- a/walletd/index.js +++ /dev/null @@ -1,119 +0,0 @@ -const { PrismaClient } = require('@prisma/client') -const { authenticatedLndGrpc, subscribeToInvoices, getInvoice, getPayment } = require('ln-service') -const dotenv = require('dotenv') -const serialize = require('../api/resolvers/serial') - -dotenv.config({ path: '..' }) - -const { lnd } = authenticatedLndGrpc({ - cert: process.env.LND_CERT, - macaroon: process.env.LND_MACAROON, - socket: process.env.LND_SOCKET -}) - -const models = new PrismaClient() - -async function recordInvoiceStatus (inv) { - console.log(inv) - if (inv.is_confirmed) { - await serialize(models, - models.$executeRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`) - } else if (inv.is_canceled) { - // mark as cancelled - await serialize(models, - models.invoice.update({ - where: { - hash: inv.id - }, - data: { - cancelled: true - } - })) - } -} - -// 1. subscribe to all invoices async -const sub = subscribeToInvoices({ lnd }) -sub.on('invoice_updated', recordInvoiceStatus) - -// 2. check all pending invoices from db in lnd -async function checkPendingInvoices () { - // invoices - const now = new Date() - const active = await models.invoice.findMany({ - where: { - expiresAt: { - gt: now - }, - cancelled: false, - confirmedAt: { - equals: null - } - } - }) - - active.forEach(async invoice => { - try { - const inv = await getInvoice({ id: invoice.hash, lnd }) - await recordInvoiceStatus(inv) - } catch (error) { - console.log(invoice, error) - process.exit(1) - } - }) -} - -async function recordWithdrawlStatus (id, wdrwl) { - console.log(wdrwl) - if (wdrwl.is_confirmed) { - // mtokens also contains the fee? - // is this true for getPayment? - const fee = Number(wdrwl.payment.fee_mtokens) - const paid = Number(wdrwl.mtokens) - fee - await serialize(models, models.$executeRaw` - SELECT confirm_withdrawl(${id}, ${paid}, ${fee})`) - } else if (wdrwl.is_failed) { - let status = 'UNKNOWN_FAILURE' - if (wdrwl.failed.is_insufficient_balance) { - status = 'INSUFFICIENT_BALANCE' - } else if (wdrwl.failed.is_invalid_payment) { - status = 'INVALID_PAYMENT' - } else if (wdrwl.failed.is_pathfinding_timeout) { - status = 'PATHFINDING_TIMEOUT' - } else if (wdrwl.failed.is_route_not_found) { - status = 'ROUTE_NOT_FOUND' - } - await serialize(models, models.$executeRaw` - SELECT reverse_withdrawl(${id}, ${status})`) - } -} - -async function checkPendingWithdrawls () { - // look for withdrawls that are 30 seconds old but don't have a status - const leftovers = await models.withdrawl.findMany({ - where: { - createdAt: { - lt: new Date(new Date().setSeconds(new Date().getSeconds() - 30)) - }, - status: { - equals: null - } - } - }) - - leftovers.forEach(async withdrawl => { - try { - const wdrwl = await getPayment({ id: withdrawl.hash, lnd }) - await recordWithdrawlStatus(withdrawl.id, wdrwl) - } catch (error) { - console.log(withdrawl, error) - process.exit(1) - } - }) - - // check withdrawls every 5 seconds - setTimeout(checkPendingWithdrawls, 5000) -} - -checkPendingInvoices() -checkPendingWithdrawls() diff --git a/worker/index.js b/worker/index.js new file mode 100644 index 00000000..39387b6b --- /dev/null +++ b/worker/index.js @@ -0,0 +1,90 @@ +const PgBoss = require('pg-boss') +const dotenv = require('dotenv') +const serialize = require('../api/resolvers/serial') +const { PrismaClient } = require('@prisma/client') +const { authenticatedLndGrpc, getInvoice, getPayment } = require('ln-service') + +dotenv.config({ path: '..' }) + +const boss = new PgBoss(process.env.DATABASE_URL) +const { lnd } = authenticatedLndGrpc({ + cert: process.env.LND_CERT, + macaroon: process.env.LND_MACAROON, + socket: process.env.LND_SOCKET +}) +const models = new PrismaClient() +const walletOptions = { startAfter: 5, retryLimit: 21, retryBackoff: true } + +boss.on('error', error => console.error(error)) + +async function work () { + await boss.start() + await boss.work('checkInvoice', checkInvoice) + await boss.work('checkWithdrawal', checkWithdrawal) + console.log('working jobs') +} + +async function checkInvoice ({ data: { hash } }) { + const inv = await getInvoice({ id: hash, lnd }) + console.log(inv) + + if (inv.is_confirmed) { + await serialize(models, + models.$executeRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`) + } else if (inv.is_canceled) { + // mark as cancelled + await serialize(models, + models.invoice.update({ + where: { + hash: inv.id + }, + data: { + cancelled: true + } + })) + } else if (new Date(inv.expires_at) > new Date()) { + // not expired, recheck in 5 seconds + boss.send('checkInvoice', { hash }, walletOptions) + } +} + +async function checkWithdrawal ({ data: { id, hash } }) { + let wdrwl + let notFound = false + try { + wdrwl = await getPayment({ id: hash, lnd }) + } catch (err) { + console.log(err) + if (err[1] === 'SentPaymentNotFound') { + notFound = true + } else { + throw err + } + } + console.log(wdrwl) + + if (wdrwl?.is_confirmed) { + const fee = Number(wdrwl.payment.fee_mtokens) + const paid = Number(wdrwl.payment.mtokens) - fee + await serialize(models, models.$executeRaw` + SELECT confirm_withdrawl(${id}, ${paid}, ${fee})`) + } else if (wdrwl?.is_failed || notFound) { + let status = 'UNKNOWN_FAILURE' + if (wdrwl?.failed.is_insufficient_balance) { + status = 'INSUFFICIENT_BALANCE' + } else if (wdrwl?.failed.is_invalid_payment) { + status = 'INVALID_PAYMENT' + } else if (wdrwl?.failed.is_pathfinding_timeout) { + status = 'PATHFINDING_TIMEOUT' + } else if (wdrwl?.failed.is_route_not_found) { + status = 'ROUTE_NOT_FOUND' + } + await serialize(models, models.$executeRaw` + SELECT reverse_withdrawl(${id}, ${status})`) + } else { + // we need to requeue to check again in 5 seconds + boss.send('checkWithdrawal', { id, hash }, walletOptions) + } +} + +work()