Compare commits

..

1 Commits

Author SHA1 Message Date
ekzyis
a8450be083 wip: refactor WebLN providers in frontend 2024-06-17 15:27:40 +02:00
682 changed files with 59051 additions and 130329 deletions

View File

@ -1,4 +0,0 @@
# Project review guidelines
- ignore ??? as a placeholder in awards.csv

View File

@ -1,8 +1,3 @@
PRISMA_SLOW_LOGS_MS=
GRAPHQL_SLOW_LOGS_MS=
NODE_ENV=development
COMPOSE_PROFILES='minimal,images,search,payments,wallets,email,capture'
############################################################################
# OPTIONAL SECRETS #
# put these in .env.local, and don't commit them to git #
@ -29,14 +24,8 @@ SLACK_BOT_TOKEN=
SLACK_CHANNEL_ID=
# lnurl ... you'll need a tunnel to localhost:3000 for these
LNAUTH_URL=http://localhost:3000/api/lnauth
LNWITH_URL=http://localhost:3000/api/lnwith
# auto social poster
TWITTER_POSTER_API_KEY=
TWITTER_POSTER_API_KEY_SECRET=
TWITTER_POSTER_ACCESS_TOKEN=
TWITTER_POSTER_ACCESS_TOKEN_SECRET=
LNAUTH_URL=
LNWITH_URL=
########################################
# SNDEV STUFF WE PRESET #
@ -63,8 +52,8 @@ INVOICE_HMAC_KEY=a4c1d9c81edb87b79d28809876a18cf72293eadb39f92f3f4f2f1cfbdf907c9
# lnd
# xxd -p -c0 docker/lnd/sn/regtest/admin.macaroon
LND_CERT=2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494943516a4343416569674177494241674951484f4a69597458736c72592f4931376933574c444354414b42676771686b6a4f50515144416a41344d5238770a485159445651514b45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d52557745775944565151444577777a4e54526d4d574e690a4f546b7a595451774868634e4d6a55774e54497a4d4467784d444d345768634e4d6a59774e7a45344d4467784d444d34576a41344d523877485159445651514b0a45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d52557745775944565151444577777a4e54526d4d574e694f546b7a595451770a5754415442676371686b6a4f5051494242676771686b6a4f50514d4242774e434141524b6d733131422b4e58554e642f54574347492b4b2b5046686b485a31410a5449647732566e766a344f6130784c696c515a4d7779647149586c7a724641485064646a3566697934584c456f43364d4e427636585277706f3448544d4948510a4d41344741315564447745422f775145417749437044415442674e56485355454444414b4267677242674546425163444154415042674e5648524d42416638450a425441444151482f4d42304741315564446751574242526f433554634e58746366464f7458393171364364337a6930327a54423542674e5648524545636a42770a6767777a4e54526d4d574e694f546b7a5954534343577876593246736147397a64494947633235666247356b6768526f62334e304c6d52765932746c636935700a626e526c636d356862494945645735706549494b64573570654842685932746c64494948596e566d59323975626f6345667741414159635141414141414141410a4141414141414141414141414159634572424941427a414b42676771686b6a4f5051514441674e494144424641694541324941462b32436746704a754e5445750a34524f63322f70625870476f4934365573724a65525972614d33414349423974424c6759777a597a2b596b5a4e7a417a7077454c754935564f505959724a6f6b0a7270754d32316b690a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a
LND_MACAROON=0201036c6e6402f801030a10ba643b9c3fe23f760e1ee63e0196656e1201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620fd0027075985f7073217aa9aaae4d14db0e7ca38f4e572c3b85c81cf6bb580b3
LND_CERT=2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494943516a43434165696741774942416749516139493834682b48653350385a437541525854554d54414b42676771686b6a4f50515144416a41344d5238770a485159445651514b45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d5255774577594456515144457778694e6a41785a5749780a4d474d354f444d774868634e4d6a51774d7a41334d5463774d6a45355768634e4d6a55774e5441794d5463774d6a4535576a41344d523877485159445651514b0a45785a73626d5167595856306232646c626d56795958526c5a43426a5a584a304d5255774577594456515144457778694e6a41785a5749784d474d354f444d770a5754415442676371686b6a4f5051494242676771686b6a4f50514d4242774e4341415365596a4b62542b4a4a4a37624b6770677a6d6c3278496130364e3174680a2f4f7033533173382b4f4a41387836647849682f326548556b4f7578675a36703549434b496f375a544c356a5963764375793941334b6e466f3448544d4948510a4d41344741315564447745422f775145417749437044415442674e56485355454444414b4267677242674546425163444154415042674e5648524d42416638450a425441444151482f4d4230474131556444675157424252545756796e653752786f747568717354727969466d6a36736c557a423542674e5648524545636a42770a676778694e6a41785a5749784d474d354f444f4343577876593246736147397a64494947633235666247356b6768526f62334e304c6d52765932746c636935700a626e526c636d356862494945645735706549494b64573570654842685932746c64494948596e566d59323975626f6345667741414159635141414141414141410a41414141414141414141414141596345724273414254414b42676771686b6a4f5051514441674e4941444246416945413873616c4a667134476671465557532f0a35347a335461746c6447736673796a4a383035425a5263334f326f434943794e6e3975716976566f5575365935345143624c3966394c575779547a516e61616e0a656977482f51696b0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a
LND_MACAROON=0201036c6e6402f801030a106cf4e146abffa5d766befbbf4c73b5a31201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e6572617465120472656164000006202c3bfd55c191e925cbffd73712c9d4b9b4a8440410bde5f8a0a6e33af8b3d876
LND_SOCKET=sn_lnd:10009
# nostr (NIP-57 zap receipts)
@ -84,8 +73,6 @@ IMGPROXY_MAX_ANIMATION_FRAME_RESOLUTION=200
IMGPROXY_READ_TIMEOUT=10
IMGPROXY_WRITE_TIMEOUT=10
IMGPROXY_DOWNLOAD_TIMEOUT=9
IMGPROXY_ENABLE_VIDEO_THUMBNAILS=1
IMGPROXY_ALLOW_ORIGIN=http://localhost:3000
# IMGPROXY_DEVELOPMENT_ERRORS_MODE=1
# IMGPROXY_ENABLE_DEBUG_HEADERS=true
@ -102,9 +89,6 @@ OPENSEARCH_MODEL_ID=
# prisma db url
DATABASE_URL="postgresql://sn:password@db:5432/stackernews?schema=public"
DB_APP_CONNECTION_LIMIT=2
DB_WORKER_CONNECTION_LIMIT=2
DB_TRANSACTION_TIMEOUT=5000
# polling intervals
NEXT_PUBLIC_FAST_POLL_INTERVAL=1000
@ -123,6 +107,8 @@ POSTGRES_DB=stackernews
# opensearch container stuff
OPENSEARCH_INITIAL_ADMIN_PASSWORD=mVchg1T5oA9wudUh
plugins.security.disabled=true
discovery.type=single-node
DISABLE_SECURITY_DASHBOARDS_PLUGIN=true
# bitcoind container stuff
@ -133,42 +119,27 @@ RPC_PORT=18443
P2P_PORT=18444
ZMQ_BLOCK_PORT=28334
ZMQ_TX_PORT=28335
ZMQ_HASHBLOCK_PORT=29000
# sn_lnd container stuff
SN_LND_REST_PORT=8080
SN_LND_GRPC_PORT=10009
SN_LND_P2P_PORT=9735
# sn lnd container stuff
LND_REST_PORT=8080
LND_GRPC_PORT=10009
LND_P2P_PORT=9735
# docker exec -u lnd sn_lnd lncli newaddress p2wkh --unused
SN_LND_PUBKEY=03dc0de8fbe29ef3d26554c615adfd17aaca959403c4e9ecebaac4b83978d86342
SN_LND_ADDR=bcrt1qu6g49vrl8n4ay99hr04wefkfy2e8g0z4nc0sjw
# sn_lndk stuff
SN_LNDK_GRPC_PORT=10012
LND_ADDR=bcrt1q7q06n5st4vqq3lssn0rtkrn2qqypghv9xg2xnl
LND_PUBKEY=02cb2e2d5a6c5b17fa67b1a883e2973c82e328fb9bd08b2b156a9e23820c87a490
# lnd container stuff
LND_REST_PORT=8081
LND_GRPC_PORT=10010
# docker exec -u lnd lnd lncli newaddress p2wkh --unused
LND_ADDR=bcrt1qfqau4ug9e6rtrvxrgclg58e0r93wshucumm9vu
LND_PUBKEY=028093ae52e011d45b3e67f2e0f2cb6c3a1d7f88d2920d408f3ac6db3a56dc4b35
# stacker lnd container stuff
STACKER_LND_REST_PORT=8081
STACKER_LND_GRPC_PORT=10010
# docker exec -u lnd stacker_lnd lncli newaddress p2wkh --unused
STACKER_LND_ADDR=bcrt1qfqau4ug9e6rtrvxrgclg58e0r93wshucumm9vu
STACKER_LND_PUBKEY=028093ae52e011d45b3e67f2e0f2cb6c3a1d7f88d2920d408f3ac6db3a56dc4b35
# cln container stuff
CLN_REST_PORT=9092
# docker exec -u clightning cln lightning-cli newaddr bech32
CLN_ADDR=bcrt1q02sqd74l4pxedy24fg0qtjz4y2jq7x4lxlgzrx
CLN_PUBKEY=03ca7acec181dbf5e427c682c4261a46a0dd9ea5f35d97acb094e399f727835b90
# sndev cli eclair getnewaddress
# sndev cli eclair getinfo
ECLAIR_ADDR="bcrt1qdus2yml69wsax3unz8pts9h979lc3s4tw0tpf6"
ECLAIR_PUBKEY="02268c74cc07837041131474881f97d497706b89a29f939555da6d094b65bd5af0"
# router lnd container stuff
ROUTER_LND_REST_PORT=8082
ROUTER_LND_GRPC_PORT=10011
# docker exec -u lnd router_lnd lncli newaddress p2wkh --unused
ROUTER_LND_ADDR=bcrt1qfkmwfpwgn6wt0dd36s79x04swz8vleyafsdpdr
ROUTER_LND_PUBKEY=02750991fbf62e57631888bc469fae69c5e658bd1d245d8ab95ed883517caa33c3
# stacker cln container stuff
STACKER_CLN_REST_PORT=9092
# docker exec -u clightning stacker_cln lightning-cli newaddr bech32
STACKER_CLN_ADDR=bcrt1q02sqd74l4pxedy24fg0qtjz4y2jq7x4lxlgzrx
STACKER_CLN_PUBKEY=03ca7acec181dbf5e427c682c4261a46a0dd9ea5f35d97acb094e399f727835b90
LNCLI_NETWORK=regtest
@ -178,22 +149,8 @@ AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
PERSISTENCE=1
SKIP_SSL_CERT_DOWNLOAD=1
# tor proxy
TOR_PROXY=http://tor:7050/
grpc_proxy=http://tor:7050/
# tor
TOR_PROXY=http://127.0.0.1:7050/
# lnbits
LNBITS_WEB_PORT=5001
LNBITS_WEB_PORT_V1=5002
# CPU shares for each category
CPU_SHARES_IMPORTANT=1024
CPU_SHARES_MODERATE=512
CPU_SHARES_LOW=256
NEXT_TELEMETRY_DISABLED=1
# custom domains stuff
# local DNS server for custom domain verification, by default it's dnsmasq.
# reachable by containers on 172.30.0.2(:53), outside of docker with 0.0.0.0:5353
DOMAINS_DNS_SERVER=172.30.0.2
LNBITS_WEB_PORT=5001

View File

@ -16,10 +16,4 @@ NEXT_PUBLIC_NORMAL_POLL_INTERVAL=30000
NEXT_PUBLIC_LONG_POLL_INTERVAL=60000
NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL=300000
NEXT_PUBLIC_URL=https://stacker.news
TOR_PROXY=http://127.0.0.1:7050/
PRISMA_SLOW_LOGS_MS=50
GRAPHQL_SLOW_LOGS_MS=50
DB_APP_CONNECTION_LIMIT=4
DB_WORKER_CONNECTION_LIMIT=2
DB_TRANSACTION_TIMEOUT=10000
NEXT_TELEMETRY_DISABLED=1
TOR_PROXY=http://127.0.0.1:7050/

35
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,35 @@
---
name: Bug report
about: Report a problem
title: ''
labels: bug
assignees: ''
---
*Note: this template is meant to help you report the bug so that we can fix it faster, ie not all of these sections are required*
**Description**
A clear and concise description of what the bug is.
**Steps to Reproduce**
A clear and concise way we might be able to reproduce the bug.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Logs**
If applicable, add your browsers console logs.
**Environment:**
If you only experience the issue on certain devices or browsers, provide that info.
- Device: [e.g. iPhone6]
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@ -1,65 +0,0 @@
name: 🐞 Bug report
description: Create a bug report to help us fix it
title: "bug report"
labels: [bug]
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
options:
- label: I have searched the existing issues
required: true
- type: textarea
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is. Include images if relevant.
placeholder: I accidentally deleted the internet. Here's my story ...
validations:
required: true
- type: textarea
attributes:
label: Screenshots
description: |
Add screenshots to help explain your problem. You can also add a video here.
Tip: You can attach images or video files by clicking this area to highlight it and then dragging files in.
validations:
required: false
- type: textarea
attributes:
label: Steps To Reproduce
description: Steps to reproduce the bug.
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll to '...'
4. See error
validations:
required: true
- type: textarea
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen
validations:
required: true
- type: textarea
attributes:
label: Logs
description: If applicable, add your browser's console logs here
- type: textarea
attributes:
label: Device information
placeholder: |
- OS: [e.g. Windows]
- Browser: [e.g. chrome, safari, firefox]
- Browser Version: [e.g. 22]
validations:
required: false
- type: textarea
attributes:
label: Additional context
description: |
Do you have links to discussions about this on SN or other references?
validations:
required: false

View File

@ -1,5 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: Questions
url: https://stacker.news/~meta
about: If you simply have a question, you can ask it in ~meta or the saloon.

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest a feature
title: ''
labels: feature
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@ -1,32 +0,0 @@
name: ✨ Feature request
description: Request a feature you'd like to see in SN!
title: "feature request"
labels: [feature]
body:
- type: markdown
attributes:
value: |
We're always looking for suggestions on how we could improve SN!
- type: textarea
attributes:
label: Describe the problem you're trying to solve
description: |
Is your feature request related to a problem? Add a clear and concise description of what the problem is.
validations:
required: true
- type: textarea
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
attributes:
label: Describe alternatives you've considered
description: |
A clear and concise description of any alternative solutions or features you have considered.
- type: textarea
attributes:
label: Additional context
description: |
Add any other additional context or screenshots about the feature request here.

View File

@ -1,23 +1,45 @@
## Description
_A clear and concise description of what you changed and why._
<!--
A clear and concise description of what you changed and why.
Don't forget to mention which tickets this closes (if any).
Use following syntax to close them automatically on merge: closes #<number>
-->
## Screenshots
<!--
If your changes are user facing, please add screenshots of the new UI.
You can also create a video to showcase your changes (useful to show UX).
-->
## Additional Context
_Was anything unclear during your work on this PR? Anything we should definitely take a closer look at?_
<!--
You can mention here anything that you think is relevant for this PR. Some examples:
* You encountered something that you didn't understand while working on this PR
* You were not sure about something you did but did not find a better way
* You initially had a different approach but went with a different approach for some reason
-->
## Checklist
**Are your changes backward compatible? Please answer below:**
**Are your changes backwards compatible? Please answer below:**
_For example, a change is not backward compatible if you removed a GraphQL field or dropped a database column._
<!-- put your answer about backwards compatibility here -->
**On a scale of 1-10 how well and how have you QA'd this change and any features it might affect? Please answer below:**
<!--
If your PR is not ready for review yet, please mark your PR as a draft.
If changes were requested, request a new review when you incorporated the feedback.
-->
**Did you QA this? Could we deploy this straight to production? Please answer below:**
<!-- put your answer about QA here -->
**For frontend changes: Tested on mobile, light and dark mode? Please answer below:**
**For frontend changes: Tested on mobile? Please answer below:**
<!-- put your answer about mobile QA here -->
**Did you introduce any new environment variables? If so, call them out explicitly here:**
<!-- put your answer about env vars here -->

View File

@ -1,60 +0,0 @@
name: extend-awards
on:
pull_request_target:
types: [ closed ]
branches:
- master
permissions:
pull-requests: write
contents: write
issues: read
jobs:
if_merged:
if: |
github.event_name == 'pull_request_target' &&
github.event.action == 'closed' &&
github.event.pull_request.merged == true &&
github.event.pull_request.head.ref != 'extend-awards/patch' &&
github.event.pull_request.user.login != 'huumn' &&
github.event.pull_request.user.login != 'ekzyis' &&
github.event.pull_request.user.login != 'Soxasora'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- run: pip install requests
- name: Check if branch exists
id: check_branch
run: |
git fetch origin extend-awards/patch || echo "Branch does not exist"
if git show-ref --verify --quiet refs/remotes/origin/extend-awards/patch; then
echo "exists=true" >> $GITHUB_ENV
else
echo "exists=false" >> $GITHUB_ENV
fi
- name: Checkout to existing branch
if: env.exists == 'true'
run: |
git checkout extend-awards/patch
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
- run: python extend-awards.py
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_CONTEXT: ${{ toJson(github) }}
- name: Commit changes and push to existing branch
if: env.exists == 'true'
run: |
git commit -am "Extending awards.csv" || exit 0
git push origin extend-awards/patch
- uses: peter-evans/create-pull-request@v7
if: env.exists == 'false'
with:
add-paths: awards.csv
branch: extend-awards/patch
commit-message: Extending awards.csv
title: Extending awards.csv
body: One or more PR's were merged that solve an issue(s) and awards.csv should be extended. Remember to delete the branch after merging.
delete-branch: true

View File

@ -1,8 +1,8 @@
name: Lint Check
name: Eslint Check
on: [pull_request]
jobs:
lint-run:
eslint-run:
runs-on: ubuntu-latest
steps:
- name: Checkout
@ -11,7 +11,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "18.20.4"
node-version: "18.17.0"
- name: Install
run: npm install

View File

@ -11,7 +11,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "18.20.4"
node-version: "18.17.0"
- name: Install
run: npm install

20
.gitignore vendored
View File

@ -20,6 +20,7 @@ node_modules/
.DS_Store
*.pem
/*.sql
lnbits/
# debug
npm-debug.log*
@ -53,25 +54,6 @@ docker-compose.*.yml
*.sql
!/prisma/migrations/*/*.sql
!/docker/db/seed.sql
!/docker/db/wallet-seed.sql
# nostr wallet connect
scripts/nwc-keys.json
# lnbits
docker/lnbits/data
# lndk
!docker/lndk/tls-*.pem
# nostr link extract
scripts/nostr-link-extract.config.json
scripts/nostr-links.db
scripts/twitter-link-extract.config.json
scripts/twitter-links.db
# pay-awards
scripts/pay-awards.config.json
# dnsmasq
docker/dnsmasq/dnsmasq.d/*

View File

@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1
FROM node:18.20.4-bullseye
FROM node:18.17.0-bullseye
ENV NODE_ENV=development

135
README.md
View File

@ -5,7 +5,7 @@
</p>
- Stacker News is trying to fix online communities with economics
- Stacker News makes internet communities that pay you Bitcoin
- What You See is What We Ship (look ma, I invented an initialism)
- 100% FOSS
- We pay bitcoin for PRs, issues, documentation, code reviews and more
@ -30,9 +30,7 @@ Go to [localhost:3000](http://localhost:3000).
- Clone the repo
- ssh: `git clone git@github.com:stackernews/stacker.news.git`
- https: `git clone https://github.com/stackernews/stacker.news.git`
- Install [docker](https://docs.docker.com/compose/install/)
- If you're running MacOS or Windows, I ***highly recommend*** using [OrbStack](https://orbstack.dev/) instead of Docker Desktop
- Please make sure that at least 10 GB of free space is available, otherwise you may encounter issues while setting up the development environment.
- Install [docker](https://docs.docker.com/get-docker/)
<br>
@ -65,58 +63,64 @@ USAGE
$ sndev help [COMMAND]
COMMANDS
help show help
help show help
env:
start start env
stop stop env
restart restart env
status status of env
logs logs from env
delete delete env
start start env
stop stop env
restart restart env
status status of env
logs logs from env
delete delete env
sn:
login login as a nym
set_balance set the balance of a nym
login login as a nym
lightning:
fund pay a bolt11 for funding
withdraw create a bolt11 for withdrawal
lnd:
fund pay a bolt11 for funding
withdraw create a bolt11 for withdrawal
cln:
cln_fund pay a bolt11 for funding with CLN
cln_withdraw create a bolt11 for withdrawal with CLN
db:
psql open psql on db
prisma run prisma commands
domains:
domains custom domains dev management
psql open psql on db
prisma run prisma commands
dev:
pr fetch and checkout a pr
lint run linters
test run tests
pr fetch and checkout a pr
lint run linters
other:
cli service cli passthrough
open open service GUI in browser
onion service onion address
cert service tls cert
compose docker compose passthrough
compose docker compose passthrough
sn_lndcli lncli passthrough on sn_lnd
stacker_lndcli lncli passthrough on stacker_lnd
stacker_clncli lightning-cli passthrough on stacker_cln
```
### Modifying services
#### Running specific services
By default all services will be run. If you want to exclude specific services from running, set `COMPOSE_PROFILES` in a `.env.local` file to one or more of `minimal,images,search,payments,wallets,email,capture`. To only run mininal necessary without things like payments in `.env.local`:
By default all services will be run. If you want to exclude specific services from running, set `COMPOSE_PROFILES` to use one or more of `minimal|images|search|payments|email|capture`. To only run mininal services without images, search, or payments:
```.env
COMPOSE_PROFILES=minimal
```sh
$ COMPOSE_PROFILES=minimal ./sndev start
```
Or, as I would recommend:
```sh
$ export COMPOSE_PROFILES=minimal
$ ./sndev start
```
To run with images and payments services:
```.env
COMPOSE_PROFILES=images,payments
```sh
$ COMPOSE_PROFILES=images,payments ./sndev start
```
#### Merging compose files
@ -134,36 +138,7 @@ services:
You can read more about [docker compose override files](https://docs.docker.com/compose/multiple-compose-files/merge/).
#### Enabling semantic search
To enable semantic search that uses text embeddings, run `./scripts/nlp-setup`.
Before running `./scripts/nlp-setup`, ensure the following are true:
- search is enabled in `COMPOSE_PROFILES`:
```.env
COMPOSE_PROFILES=...,search,...
```
- The default opensearch index (default name=`item`) is created and done indexing. This should happen the first time you run `./sndev start`, but it may take a few minutes for indexing to complete.
After `nlp-setup` is done, restart your containers to enable semantic search:
```
> ./sndev restart
```
#### Local DNS via dnsmasq
To enable dnsmasq:
- domains should be enabled in `COMPOSE_PROFILES`:
```.env
COMPOSE_PROFILES=...,domains,...
```
To add/remove DNS records you can now use `./sndev domains dns`. More on this [here](#add-or-remove-dns-records-in-local).
<br>
@ -258,7 +233,6 @@ _Due to Rule 3, make sure that you mark your PR as a draft when you create it an
| tag | multiplier |
| ----------------- | ---------- |
| `priority:low` | 0.5 |
| `priority:medium` | 1.5 |
| `priority:high` | 2 |
| `priority:urgent` | 3 |
@ -396,11 +370,9 @@ You can connect to the local database via `./sndev psql`. [psql](https://www.pos
<br>
## Running cli on local lightning nodes
## Running lncli on the local lnd nodes
You can run `lncli` on the local lnd nodes via `./sndev cli lnd` and `./sndev cli sn_lnd`. The node for your local SN instance is `sn_lnd` and the node serving as any external node, like a stacker's node or external wallet, is `lnd`.
You can run `lightning-cli` on the local cln node via `./sndev cli cln` which serves as an external node or wallet.
You can run `lncli` on the local lnd nodes via `./sndev sn_lncli` and `./sndev stacker_lncli`. The node for your local SN instance is `sn_lnd` and the node serving as any external node, like a stacker's node or external wallet, is `stacker_lnd`.
<br>
@ -459,29 +431,10 @@ GITHUB_SECRET=<Client secret>
## Enabling web push notifications
To enable Web Push locally, you will need to set the `VAPID_*` env vars. `VAPID_MAILTO` needs to be an email address using the `mailto:` scheme. For `NEXT_PUBLIC_VAPID_PUBKEY` and `VAPID_PRIVKEY`, you can run `npx web-push generate-vapid-keys`.
To enable Web Push locally, you will need to set the `VAPID_*` env vars. `VAPID_MAILTO` needs to be an email address using the `mailto:` scheme. For `NEXT_PUBLIC_VAPID_KEY` and `VAPID_PRIVKEY`, you can run `npx web-push generate-vapid-keys`.
<br>
## Custom domains
### Add or remove DNS records in local
A worker dedicated to verifying custom domains, checks, among other things, if a domain has the correct DNS records and values. This would normally require a real domain and access to its DNS configuration. Therefore we use dnsmasq to have local DNS, make sure you have [enabled it](#local-dns-via-dnsmasq).
To add a DNS record the syntax is the following:
`./sndev domains dns add|remove cname|txt <name/domain> <value>`
For TXT records, you can also use `""` quoted strings on `value`.
To list all DNS records present in the dnsmasq config: `./sndev domains dns list`
#### Access a local custom domain added via dnsmasq
sndev will use the dnsmasq DNS server by default, but chances are that you might want to access the domain via your browser.
For every edit on dnsmasq, it will give you the option to either edit the `/etc/hosts` file or use the dnsmasq DNS server which can be reached on `127.0.0.1:5353`. You can avoid getting asked to edit the `/etc/hosts` file by adding the `--no-hosts` parameter.
# Internals
<br>
@ -506,9 +459,7 @@ In addition, we run other critical services the above services interact with lik
## Wallet transaction safety
To ensure stackers balances are kept sane, some wallet updates are run in [serializable transactions](https://www.postgresql.org/docs/current/transaction-iso.html#XACT-SERIALIZABLE) at the database level. Because early versions of prisma had relatively poor support for transactions most wallet touching code is written in [plpgsql](https://www.postgresql.org/docs/current/plpgsql.html) stored procedures and can be found in the `prisma/migrations` folder.
*UPDATE*: Most wallet updates are now run in [read committed](https://www.postgresql.org/docs/current/transaction-iso.html#XACT-READ-COMMITTED) transactions. See `api/paidAction/README.md` for more information.
To ensure stackers balances are kept sane, all wallet updates are run in [serializable transactions](https://www.postgresql.org/docs/current/transaction-iso.html#XACT-SERIALIZABLE) at the database level. Because early versions of prisma had relatively poor support for transactions most wallet touching code is written in [plpgsql](https://www.postgresql.org/docs/current/plpgsql.html) stored procedures and can be found in the `prisma/migrations` folder.
<br>
@ -519,7 +470,7 @@ Open a [discussion](http://github.com/stackernews/stacker.news/discussions) or [
# Responsible disclosure
If you found a vulnerability, we would greatly appreciate it if you contact us via [security@stacker.news](mailto:security@stacker.news) or open a [security advisory](https://github.com/stackernews/stacker.news/security/advisories/new). Our PGP key can be found [here](https://stacker.news/pgp.txt) (FEE1 E768 E0B3 81F5).
If you found a vulnerability, we would greatly appreciate it if you contact us via [security@stacker.news](mailto:security@stacker.news) or open a [security advisory](https://github.com/stackernews/stacker.news/security/advisories/new). Our PGP key can be found [here](https://stacker.news/pgp.txt) (EBAF 75DA 7279 CB48).
<br>

View File

@ -1,20 +1,13 @@
import { cachedFetcher } from '@/lib/fetch'
import { toPositiveNumber } from '@/lib/format'
import { authenticatedLndGrpc } from '@/lib/lnd'
import { getIdentity, getHeight, getWalletInfo, getNode, getPayment, parsePaymentRequest } from 'ln-service'
import { datePivot } from '@/lib/time'
import { LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants'
import lndService from 'ln-service'
const lnd = global.lnd || authenticatedLndGrpc({
const { lnd } = lndService.authenticatedLndGrpc({
cert: process.env.LND_CERT,
macaroon: process.env.LND_MACAROON,
socket: process.env.LND_SOCKET
}).lnd
if (process.env.NODE_ENV === 'development') global.lnd = lnd
})
// Check LND GRPC connection
getWalletInfo({ lnd }, (err, result) => {
lndService.getWalletInfo({ lnd }, (err, result) => {
if (err) {
console.error('LND GRPC connection error')
return
@ -22,181 +15,4 @@ getWalletInfo({ lnd }, (err, result) => {
console.log('LND GRPC connection successful')
})
export async function estimateRouteFee ({ lnd, destination, tokens, mtokens, request, timeout }) {
// if the payment request includes us as route hint, we needd to use the destination and amount
// otherwise, this will fail with a self-payment error
if (request) {
const inv = parsePaymentRequest({ request })
const ourPubkey = await getOurPubkey({ lnd })
if (Array.isArray(inv.routes)) {
for (const route of inv.routes) {
if (Array.isArray(route)) {
for (const hop of route) {
if (hop.public_key === ourPubkey) {
console.log('estimateRouteFee ignoring self-payment route')
request = false
break
}
}
}
}
}
}
return await new Promise((resolve, reject) => {
const params = {}
if (request) {
console.log('estimateRouteFee using payment request')
params.payment_request = request
} else {
console.log('estimateRouteFee using destination and amount')
params.dest = Buffer.from(destination, 'hex')
params.amt_sat = tokens ? toPositiveNumber(tokens) : toPositiveNumber(BigInt(mtokens) / BigInt(1e3))
}
lnd.router.estimateRouteFee({
...params,
timeout
}, (err, res) => {
if (err) {
if (res?.failure_reason) {
reject(new Error(`Unable to estimate route: ${res.failure_reason}`))
} else {
reject(err)
}
return
}
if (res.routing_fee_msat < 0 || res.time_lock_delay <= 0) {
reject(new Error('Unable to estimate route, excessive values: ' + JSON.stringify(res)))
return
}
resolve({
routingFeeMsat: toPositiveNumber(res.routing_fee_msat),
timeLockDelay: toPositiveNumber(res.time_lock_delay)
})
})
})
}
// created_height is the accepted_height, timeout is the expiry height
// ln-service remaps the `htlcs` field of lookupInvoice to `payments` and
// see: https://github.com/alexbosworth/lightning/blob/master/lnd_responses/htlc_as_payment.js
// and: https://lightning.engineering/api-docs/api/lnd/lightning/lookup-invoice/index.html#lnrpcinvoicehtlc
export function hodlInvoiceCltvDetails (inv) {
if (!inv.payments) {
throw new Error('No payments found')
}
if (!inv.is_held) {
throw new Error('Invoice is not held')
}
const acceptHeight = inv.payments.reduce((max, htlc) => {
const createdHeight = toPositiveNumber(htlc.created_height)
return createdHeight > max ? createdHeight : max
}, 0)
const expiryHeight = inv.payments.reduce((min, htlc) => {
const timeout = toPositiveNumber(htlc.timeout)
return timeout < min ? timeout : min
}, Number.MAX_SAFE_INTEGER)
return {
expiryHeight: toPositiveNumber(expiryHeight),
acceptHeight: toPositiveNumber(acceptHeight)
}
}
export function getPaymentFailureStatus (withdrawal) {
if (withdrawal && !withdrawal.is_failed) {
throw new Error('withdrawal is not failed')
}
if (withdrawal?.failed?.is_insufficient_balance) {
return {
status: 'INSUFFICIENT_BALANCE',
message: 'you didn\'t have enough sats'
}
} else if (withdrawal?.failed?.is_invalid_payment) {
return {
status: 'INVALID_PAYMENT',
message: 'invalid payment'
}
} else if (withdrawal?.failed?.is_pathfinding_timeout) {
return {
status: 'PATHFINDING_TIMEOUT',
message: 'no route found'
}
} else if (withdrawal?.failed?.is_route_not_found) {
return {
status: 'ROUTE_NOT_FOUND',
message: 'no route found'
}
}
return {
status: 'UNKNOWN_FAILURE',
message: 'unknown failure'
}
}
export const getBlockHeight = cachedFetcher(async function fetchBlockHeight ({ lnd, ...args }) {
try {
const { current_block_height: height } = await getHeight({ lnd, ...args })
return height
} catch (err) {
throw new Error(`Unable to fetch block height: ${err.message}`)
}
}, {
maxSize: 1,
cacheExpiry: 60 * 1000, // 1 minute
forceRefreshThreshold: 5 * 60 * 1000, // 5 minutes
keyGenerator: () => 'getHeight'
})
export const getOurPubkey = cachedFetcher(async function fetchOurPubkey ({ lnd, ...args }) {
try {
const identity = await getIdentity({ lnd, ...args })
return identity.public_key
} catch (err) {
throw new Error(`Unable to fetch identity: ${err.message}`)
}
}, {
maxSize: 1,
cacheExpiry: 0, // never expire
forceRefreshThreshold: 0, // never force refresh
keyGenerator: () => 'getOurPubkey'
})
export const getNodeSockets = cachedFetcher(async function fetchNodeSockets ({ lnd, ...args }) {
try {
return (await getNode({ lnd, is_omitting_channels: true, ...args }))?.sockets
} catch (err) {
throw new Error(`Unable to fetch node info: ${err.message}`)
}
}, {
maxSize: 100,
cacheExpiry: 1000 * 60 * 60 * 24, // 1 day
forceRefreshThreshold: 1000 * 60 * 60 * 24 * 7, // 1 week
keyGenerator: (args) => {
const { public_key: publicKey } = args
return publicKey
}
})
export async function getPaymentOrNotSent ({ id, lnd, createdAt }) {
try {
return await getPayment({ id, lnd })
} catch (err) {
if (err[1] === 'SentPaymentNotFound' &&
createdAt < datePivot(new Date(), { milliseconds: -LND_PATHFINDING_TIMEOUT_MS * 2 })) {
// if the payment is older than 2x timeout, but not found in LND, we can assume it errored before lnd stored it
return { notSent: true, is_failed: true }
} else {
throw err
}
}
}
export default lnd

View File

@ -1,12 +1,18 @@
import createPrisma from '@/lib/create-prisma'
import { PrismaClient } from '@prisma/client'
const prisma = global.prisma || (() => {
console.log('initing prisma')
return createPrisma({
connectionParams: {
connection_limit: process.env.DB_APP_CONNECTION_LIMIT
const prisma = new PrismaClient({
log: [{ level: 'query', emit: 'event' }, 'warn', 'error']
})
prisma.$on('query', (e) => {
if (e.duration > 50) {
console.log('Query: ' + e.query)
console.log('Params: ' + e.params)
console.log('Duration: ' + e.duration + 'ms')
}
})
return prisma
})()
if (process.env.NODE_ENV === 'development') global.prisma = prisma

3
api/package.json Normal file
View File

@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@ -1,371 +0,0 @@
# Paid Actions
Paid actions are actions that require payments to perform. Given that we support several payment flows, some of which require more than one round of communication either with LND or the client, and several paid actions, we have this plugin-like interface to easily add new paid actions.
<details>
<summary>internals</summary>
All paid action progress, regardless of flow, is managed using a state machine that's transitioned by the invoice progress and payment progress (in the case of p2p paid action). Below is the full state machine for paid actions:
```mermaid
stateDiagram-v2
[*] --> PENDING
PENDING --> PAID
PENDING --> CANCELING
PENDING --> FAILED
PAID --> [*]
CANCELING --> FAILED
FAILED --> RETRYING
FAILED --> [*]
RETRYING --> [*]
[*] --> PENDING_HELD
PENDING_HELD --> HELD
PENDING_HELD --> FORWARDING
PENDING_HELD --> CANCELING
PENDING_HELD --> FAILED
HELD --> PAID
HELD --> CANCELING
HELD --> FAILED
FORWARDING --> FORWARDED
FORWARDING --> FAILED_FORWARD
FORWARDED --> PAID
FAILED_FORWARD --> CANCELING
FAILED_FORWARD --> FAILED
```
</details>
## Payment Flows
There are three payment flows:
### Fee credits
The stacker has enough fee credits to pay for the action. This is the simplest flow and is similar to a normal request.
### Optimistic
The optimistic flow is useful for actions that require immediate feedback to the client, but don't require the action to be immediately visible to everyone else.
For paid actions that support it, if the stacker doesn't have enough fee credits, we store the action in a `PENDING` state on the server, which is visible only to the stacker, then return a payment request to the client. The client then pays the invoice however and whenever they wish, and the server monitors payment progress. If the payment succeeds, the action is executed fully becoming visible to everyone and is marked as `PAID`. Otherwise, the action is marked as `FAILED`, the client is notified the payment failed and the payment can be retried.
<details>
<summary>Internals</summary>
Internally, optimistic flows make use of a state machine that's transitioned by the invoice payment progress.
```mermaid
stateDiagram-v2
[*] --> PENDING
PENDING --> PAID
PENDING --> CANCELING
PENDING --> FAILED
PAID --> [*]
CANCELING --> FAILED
FAILED --> RETRYING
FAILED --> [*]
RETRYING --> [*]
```
</details>
### Pessimistic
For paid actions that don't support optimistic actions (or when the stacker is `@anon`), if the client doesn't have enough fee credits, we return a payment request to the client without performing the action and only storing the action's arguments. After the client pays the invoice, the server performs the action with original arguments. Pessimistic actions require the payment to complete before being visible to them and everyone else.
Internally, pessimistic flows use hold invoices. If the action doesn't succeed, the payment is cancelled and it's as if the payment never happened (ie it's a lightning native refund mechanism).
<details>
<summary>Internals</summary>
Internally, pessimistic flows make use of a state machine that's transitioned by the invoice payment progress much like optimistic flows, but with extra steps.
```mermaid
stateDiagram-v2
PAID --> [*]
CANCELING --> FAILED
FAILED --> [*]
[*] --> PENDING_HELD
PENDING_HELD --> HELD
PENDING_HELD --> CANCELING
PENDING_HELD --> FAILED
HELD --> PAID
HELD --> CANCELING
HELD --> FAILED
```
</details>
### Table of existing paid actions and their supported flows
| action | fee credits | optimistic | pessimistic | anonable | qr payable | p2p wrapped | side effects | reward sats | p2p direct |
| ----------------- | ----------- | ---------- | ----------- | -------- | ---------- | ----------- | ------------ | ----------- | ---------- |
| zaps | x | x | x | x | x | x | x | | |
| posts | x | x | x | x | x | | x | x | |
| comments | x | x | x | x | x | | x | x | |
| downzaps | x | x | | | x | | x | x | |
| poll votes | x | x | | | x | | | x | |
| territory actions | x | | x | | x | | | x | |
| donations | x | | x | x | x | | | x | |
| update posts | x | | x | | x | | x | x | |
| update comments | x | | x | | x | | x | x | |
| receive | | | | | x | x | x | | x |
| buy fee credits | | | x | | x | | | x | |
| invite gift | x | | | | | | x | x | |
## Not-custodial zaps (ie p2p wrapped payments)
Zaps, and possibly other future actions, can be performed peer to peer and non-custodially. This means that the payment is made directly from the client to the recipient, without the server taking custody of the funds. Currently, in order to trigger this behavior, the recipient must have a receiving wallet attached and the sender must have insufficient funds in their custodial wallet to perform the requested zap.
This works by requesting an invoice from the recipient's wallet and reusing the payment hash in a hold invoice paid to SN (to collect the sybil fee) which we serve to the sender. When the sender pays this wrapped invoice, we forward our own money to the recipient, who then reveals the preimage to us, allowing us to settle the wrapped invoice and claim the sender's funds. This effectively does what a lightning node does when forwarding a payment but allows us to do it at the application layer.
<details>
<summary>Internals</summary>
Internally, p2p wrapped payments make use of the same paid action state machine but it's transitioned by both the incoming invoice payment progress *and* the outgoing invoice payment progress.
```mermaid
stateDiagram-v2
PAID --> [*]
CANCELING --> FAILED
FAILED --> RETRYING
FAILED --> [*]
RETRYING --> [*]
[*] --> PENDING_HELD
PENDING_HELD --> FORWARDING
PENDING_HELD --> CANCELING
PENDING_HELD --> FAILED
FORWARDING --> FORWARDED
FORWARDING --> FAILED_FORWARD
FORWARDED --> PAID
FAILED_FORWARD --> CANCELING
FAILED_FORWARD --> FAILED
```
</details>
## Paid Action Interface
Each paid action is implemented in its own file in the `paidAction` directory. Each file exports a module with the following properties:
### Boolean flags
- `anonable`: can be performed anonymously
### Payment methods
- `paymentMethods`: an array of payment methods that the action supports ordered from most preferred to least preferred
- P2P: a p2p payment made directly from the client to the recipient
- after wrapping the invoice, anonymous users will follow a PESSIMISTIC flow to pay the invoice and logged in users will follow an OPTIMISTIC flow
- FEE_CREDIT: a payment made from the user's fee credit balance
- OPTIMISTIC: an optimistic payment flow
- PESSIMISTIC: a pessimistic payment flow
### Functions
All functions have the following signature: `function(args: Object, context: Object): Promise`
- `getCost`: returns the cost of the action in msats as a `BigInt`
- `perform`: performs the action
- returns: an object with the result of the action as defined in the `graphql` schema
- if the action supports optimism and an `invoiceId` is provided, the action should be performed optimistically
- any action data that needs to be hidden while it's pending, should store in its rows a `PENDING` state along with its `invoiceId`
- it can optionally store in the invoice with the `invoiceId` the `actionId` to be able to link the action with the invoice regardless of retries
- `onPaid`: called when the action is paid
- if the action does not support optimism, this function is optional
- this function should be used to mark the rows created in `perform` as `PAID` and perform critical side effects of the action (like denormalizations)
- `nonCriticalSideEffects`: called after the action is paid to run any side effects whose failure does not affect the action's execution
- this function is always optional
- it's passed the result of the action (or the action's paid invoice) and the current context
- this is where things like push notifications should be handled
- `onFail`: called when the action fails
- if the action does not support optimism, this function is optional
- this function should be used to mark the rows created in `perform` as `FAILED`
- `retry`: called when the action is retried with any new invoice information
- return: an object with the result of the action as defined in the `graphql` schema (same as `perform`)
- this function is called when an optimistic action is retried
- it's passed the original `invoiceId` and the `newInvoiceId`
- this function should update the rows created in `perform` to contain the new `newInvoiceId` and remark the row as `PENDING`
- `getInvoiceablePeer`: returns the userId of the peer that's capable of generating an invoice so they can be paid for the action
- this is only used for p2p wrapped zaps currently
- `describe`: returns a description as a string of the action
- for actions that require generating an invoice, and for stackers that don't hide invoice descriptions, this is used in the invoice description
- `getSybilFeePercent` (required if `getInvoiceablePeer` is implemented): returns the action sybil fee percent as a `BigInt` (eg. 30n for 30%)
#### Function arguments
`args` contains the arguments for the action as defined in the `graphql` schema. If the action is optimistic or pessimistic, `args` will contain an `invoiceId` field which can be stored alongside the paid action's data. If this is a call to `retry`, `args` will contain the original `invoiceId` and `newInvoiceId` fields.
`context` contains the following fields:
- `me`: the user performing the action (undefined if anonymous)
- `cost`: the cost of the action in msats as a `BigInt`
- `sybilFeePercent`: the sybil fee percent as a `BigInt` (eg. 30n for 30%)
- `tx`: the current transaction (for anything that needs to be done atomically with the payment)
- `models`: the current prisma client (for anything that doesn't need to be done atomically with the payment)
- `lnd`: the current lnd client
## Recording Cowboy Credits
To avoid adding sats and credits together everywhere to show an aggregate sat value, in most cases we denormalize a `sats` field that carries the "sats value", the combined sats + credits of something, and a `credits` field that carries only the earned `credits`. For example, the `Item` table has an `msats` field that carries the sum of the `mcredits` and `msats` earned and a `mcredits` field that carries the value of the `mcredits` earned. So, the sats value an item earned is `item.msats` BUT the real sats earned is `item.msats - item.mcredits`.
The ONLY exception to this are for the `users` table where we store a stacker's rewards sats and credits balances separately.
## `IMPORTANT: transaction isolation`
We use a `read committed` isolation level for actions. This means paid actions need to be mindful of concurrency issues. Specifically, reading data from the database and then writing it back in `read committed` is a common source of consistency bugs (aka serialization anamolies).
### This is a big deal
1. If you read from the database and intend to use that data to write to the database, and it's possible that a concurrent transaction could change the data you've read (it usually is), you need to be prepared to handle that (see [_phantom reads_](https://www.postgresql.org/docs/16/transaction-iso.html)).
2. This applies to **ALL**, and I really mean **ALL**, read data regardless of how you read the data within the `read committed` transaction:
- independent statements
- `WITH` queries (CTEs) in the same statement
- subqueries in the same statement
### How to handle it
1. take row level locks on the rows you read, using something like a `SELECT ... FOR UPDATE` statement
- NOTE: this does not protect against missing concurrent inserts. It only prevents concurrent updates to the rows you've already read.
- read about row level locks available in postgres: https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-ROWS
2. check that the data you read is still valid before writing it back to the database i.e. optimistic concurrency control
- NOTE: this does not protect against missing concurrent inserts. It only prevents concurrent updates to the rows you've already read.
3. avoid having to read data from one row to modify the data of another row all together
### Example
Let's say you are aggregating total sats for an item from a table `zaps` and updating the total sats for that item in another table `item_zaps`. Two 100 sat zaps are requested for the same item at the same time in two concurrent transactions. The total sats for the item should be 200, but because of the way `read committed` works, the following statements lead to a total sats of 100:
*the statements here are listed in the order they are executed, but each transaction is happening concurrently*
#### Incorrect
```sql
-- transaction 1
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1;
-- total_sats is 100
-- transaction 2
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1;
-- total_sats is still 100, because transaction 1 hasn't committed yet
-- transaction 1
UPDATE item_zaps SET sats = total_sats WHERE item_id = 1;
-- sets sats to 100
-- transaction 2
UPDATE item_zaps SET sats = total_sats WHERE item_id = 1;
-- sets sats to 100
COMMIT;
-- transaction 1
COMMIT;
-- item_zaps.sats is 100, but we would expect it to be 200
```
Note that row level locks wouldn't help in this case, because we can't lock the rows that the transactions don't know to exist yet.
#### Subqueries are still incorrect
```sql
-- transaction 1
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
UPDATE item_zaps SET sats = (SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1) WHERE item_id = 1;
-- item_zaps.sats is 100
-- transaction 2
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
UPDATE item_zaps SET sats = (SELECT sum(sats) INTO total_sats FROM zaps WHERE item_id = 1) WHERE item_id = 1;
-- item_zaps.sats is still 100, because transaction 1 hasn't committed yet
-- transaction 1
COMMIT;
-- transaction 2
COMMIT;
-- item_zaps.sats is 100, but we would expect it to be 200
```
Note that while the `UPDATE` transaction 2's update statement will block until transaction 1 commits, the subquery is computed before it blocks and is not re-evaluated after the block.
#### Correct
```sql
-- transaction 1
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
-- transaction 2
BEGIN;
INSERT INTO zaps (item_id, sats) VALUES (1, 100);
-- transaction 1
UPDATE item_zaps SET sats = sats + 100 WHERE item_id = 1;
-- transaction 2
UPDATE item_zaps SET sats = sats + 100 WHERE item_id = 1;
COMMIT;
-- transaction 1
COMMIT;
-- item_zaps.sats is 200
```
The above works because `UPDATE` takes a lock on the rows it's updating, so transaction 2 will block until transaction 1 commits, and once transaction 2 is unblocked, it will re-evaluate the `sats` value of the row it's updating.
#### More resources
- https://stackoverflow.com/questions/61781595/postgres-read-commited-doesnt-re-read-updated-row?noredirect=1#comment109279507_61781595
- https://www.cybertec-postgresql.com/en/transaction-anomalies-with-select-for-update/
From the [postgres docs](https://www.postgresql.org/docs/current/transaction-iso.html#XACT-READ-COMMITTED):
> UPDATE, DELETE, SELECT FOR UPDATE, and SELECT FOR SHARE commands behave the same as SELECT in terms of searching for target rows: they will only find target rows that were committed as of the command start time. However, such a target row might have already been updated (or deleted or locked) by another concurrent transaction by the time it is found. In this case, the would-be updater will wait for the first updating transaction to commit or roll back (if it is still in progress). If the first updater rolls back, then its effects are negated and the second updater can proceed with updating the originally found row. If the first updater commits, the second updater will ignore the row if the first updater deleted it, otherwise it will attempt to apply its operation to the updated version of the row. The search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches the search condition. If so, the second updater proceeds with its operation using the updated version of the row. In the case of SELECT FOR UPDATE and SELECT FOR SHARE, this means it is the updated version of the row that is locked and returned to the client.
From the [postgres source docs](https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/executor/README#l350):
> It is also possible that there are relations in the query that are not to be locked (they are neither the UPDATE/DELETE/MERGE target nor specified to be locked in SELECT FOR UPDATE/SHARE). When re-running the test query ***we want to use the same rows*** from these relations that were joined to the locked rows.
## `IMPORTANT: deadlocks`
Deadlocks can occur when two transactions are waiting for each other to release locks. This can happen when two transactions lock rows in different orders whether explicit or implicit.
If both transactions lock the rows in the same order, the deadlock is avoided.
### Incorrect
```sql
-- transaction 1
BEGIN;
UPDATE users set msats = msats + 1 WHERE id = 1;
-- transaction 2
BEGIN;
UPDATE users set msats = msats + 1 WHERE id = 2;
-- transaction 1 (blocks here until transaction 2 commits)
UPDATE users set msats = msats + 1 WHERE id = 2;
-- transaction 2 (blocks here until transaction 1 commits)
UPDATE users set msats = msats + 1 WHERE id = 1;
-- deadlock occurs because neither transaction can proceed to here
```
In practice, this most often occurs when selecting multiple rows for update in different orders. Recently, we had a deadlock when spliting zaps to multiple users. The solution was to select the rows for update in the same order.
### Incorrect
```sql
WITH forwardees AS (
SELECT "userId", (($1::BIGINT * pct) / 100)::BIGINT AS msats
FROM "ItemForward"
WHERE "itemId" = $2::INTEGER
),
UPDATE users
SET
msats = users.msats + forwardees.msats,
"stackedMsats" = users."stackedMsats" + forwardees.msats
FROM forwardees
WHERE users.id = forwardees."userId";
```
If forwardees are selected in a different order in two concurrent transactions, e.g. (1,2) in tx 1 and (2,1) in tx 2, a deadlock can occur. To avoid this, always select rows for update in the same order.
### Correct
We fixed the deadlock by selecting the forwardees in the same order in these transactions.
```sql
WITH forwardees AS (
SELECT "userId", (($1::BIGINT * pct) / 100)::BIGINT AS msats
FROM "ItemForward"
WHERE "itemId" = $2::INTEGER
ORDER BY "userId" ASC
),
UPDATE users
SET
msats = users.msats + forwardees.msats,
"stackedMsats" = users."stackedMsats" + forwardees.msats
FROM forwardees
WHERE users.id = forwardees."userId";
```
### More resources
- https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-DEADLOCKS

View File

@ -1,82 +0,0 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { msatsToSats, satsToMsats } from '@/lib/format'
export const anonable = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
]
export async function getCost ({ sats }) {
return satsToMsats(sats)
}
export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, tx }) {
itemId = parseInt(itemId)
let invoiceData = {}
if (invoiceId) {
invoiceData = { invoiceId, invoiceActionState: 'PENDING' }
// store a reference to the item in the invoice
await tx.invoice.update({
where: { id: invoiceId },
data: { actionId: itemId }
})
}
const act = await tx.itemAct.create({ data: { msats: cost, itemId, userId: me.id, act: 'BOOST', ...invoiceData } })
const [{ path }] = await tx.$queryRaw`
SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER`
return { id: itemId, sats, act: 'BOOST', path, actId: act.id }
}
export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) {
await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
const [{ id, path }] = await tx.$queryRaw`
SELECT "Item".id, ltree2text(path) as path
FROM "Item"
JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId"
WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER`
return { id, sats: msatsToSats(cost), act: 'BOOST', path }
}
export async function onPaid ({ invoice, actId }, { tx }) {
let itemAct
if (invoice) {
await tx.itemAct.updateMany({
where: { invoiceId: invoice.id },
data: {
invoiceActionState: 'PAID'
}
})
itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id } })
} else if (actId) {
itemAct = await tx.itemAct.findFirst({ where: { id: actId } })
} else {
throw new Error('No invoice or actId')
}
// increment boost on item
await tx.item.update({
where: { id: itemAct.itemId },
data: {
boost: { increment: msatsToSats(itemAct.msats) }
}
})
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil)
VALUES ('expireBoost', jsonb_build_object('id', ${itemAct.itemId}::INTEGER), 21, true,
now() + interval '30 days', now() + interval '40 days')`
}
export async function onFail ({ invoice }, { tx }) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
}
export async function describe ({ id: itemId, sats }, { actionId, cost }) {
return `SN: boost ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}`
}

View File

@ -1,32 +0,0 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
export const anonable = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ credits }) {
return satsToMsats(credits)
}
export async function perform ({ credits }, { me, cost, tx }) {
await tx.user.update({
where: { id: me.id },
data: {
mcredits: {
increment: cost
}
}
})
return {
credits
}
}
export async function describe () {
return 'SN: buy fee credits'
}

View File

@ -1,29 +0,0 @@
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
export const anonable = true
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ sats }) {
return satsToMsats(sats)
}
export async function perform ({ sats }, { me, tx }) {
await tx.donation.create({
data: {
sats,
userId: me?.id ?? USER_ID.anon
}
})
return { sats }
}
export async function describe (args, context) {
return 'SN: donate to rewards pool'
}

View File

@ -1,100 +0,0 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { msatsToSats, satsToMsats } from '@/lib/format'
import { Prisma } from '@prisma/client'
export const anonable = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
]
export async function getCost ({ sats }) {
return satsToMsats(sats)
}
export async function perform ({ invoiceId, sats, id: itemId }, { me, cost, tx }) {
itemId = parseInt(itemId)
let invoiceData = {}
if (invoiceId) {
invoiceData = { invoiceId, invoiceActionState: 'PENDING' }
// store a reference to the item in the invoice
await tx.invoice.update({
where: { id: invoiceId },
data: { actionId: itemId }
})
}
const itemAct = await tx.itemAct.create({
data: { msats: cost, itemId, userId: me.id, act: 'DONT_LIKE_THIS', ...invoiceData }
})
const [{ path }] = await tx.$queryRaw`SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER`
return { id: itemId, sats, act: 'DONT_LIKE_THIS', path, actId: itemAct.id }
}
export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) {
await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
const [{ id, path }] = await tx.$queryRaw`
SELECT "Item".id, ltree2text(path) as path
FROM "Item"
JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId"
WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER`
return { id, sats: msatsToSats(cost), act: 'DONT_LIKE_THIS', path }
}
export async function onPaid ({ invoice, actId }, { tx }) {
let itemAct
if (invoice) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } })
itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id }, include: { item: true } })
} else if (actId) {
itemAct = await tx.itemAct.findUnique({ where: { id: actId }, include: { item: true } })
} else {
throw new Error('No invoice or actId')
}
const msats = BigInt(itemAct.msats)
const sats = msatsToSats(msats)
// denormalize downzaps
await tx.$executeRaw`
WITH territory AS (
SELECT COALESCE(r."subName", i."subName", 'meta')::CITEXT as "subName"
FROM "Item" i
LEFT JOIN "Item" r ON r.id = i."rootId"
WHERE i.id = ${itemAct.itemId}::INTEGER
), zapper AS (
SELECT
COALESCE(${itemAct.item.parentId
? Prisma.sql`"zapCommentTrust"`
: Prisma.sql`"zapPostTrust"`}, 0) as "zapTrust",
COALESCE(${itemAct.item.parentId
? Prisma.sql`"subZapCommentTrust"`
: Prisma.sql`"subZapPostTrust"`}, 0) as "subZapTrust"
FROM territory
LEFT JOIN "UserSubTrust" ust ON ust."subName" = territory."subName"
AND ust."userId" = ${itemAct.userId}::INTEGER
), zap AS (
INSERT INTO "ItemUserAgg" ("userId", "itemId", "downZapSats")
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
ON CONFLICT ("itemId", "userId") DO UPDATE
SET "downZapSats" = "ItemUserAgg"."downZapSats" + ${sats}::INTEGER, updated_at = now()
RETURNING LOG("downZapSats" / GREATEST("downZapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
)
UPDATE "Item"
SET "weightedDownVotes" = "weightedDownVotes" + zapper."zapTrust" * zap.log_sats,
"subWeightedDownVotes" = "subWeightedDownVotes" + zapper."subZapTrust" * zap.log_sats
FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER`
}
export async function onFail ({ invoice }, { tx }) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
}
export async function describe ({ id: itemId, sats }, { cost, actionId }) {
return `SN: downzap of ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}`
}

View File

@ -1,500 +0,0 @@
import { createHodlInvoice, createInvoice, parsePaymentRequest } from 'ln-service'
import { datePivot } from '@/lib/time'
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { createHmac } from '@/api/resolvers/wallet'
import { Prisma } from '@prisma/client'
import { createWrappedInvoice, createUserInvoice } from '@/wallets/server'
import { assertBelowMaxPendingInvoices, assertBelowMaxPendingDirectPayments } from './lib/assert'
import * as ITEM_CREATE from './itemCreate'
import * as ITEM_UPDATE from './itemUpdate'
import * as ZAP from './zap'
import * as DOWN_ZAP from './downZap'
import * as POLL_VOTE from './pollVote'
import * as TERRITORY_CREATE from './territoryCreate'
import * as TERRITORY_UPDATE from './territoryUpdate'
import * as TERRITORY_BILLING from './territoryBilling'
import * as TERRITORY_UNARCHIVE from './territoryUnarchive'
import * as DONATE from './donate'
import * as BOOST from './boost'
import * as RECEIVE from './receive'
import * as BUY_CREDITS from './buyCredits'
import * as INVITE_GIFT from './inviteGift'
export const paidActions = {
ITEM_CREATE,
ITEM_UPDATE,
ZAP,
DOWN_ZAP,
BOOST,
POLL_VOTE,
TERRITORY_CREATE,
TERRITORY_UPDATE,
TERRITORY_BILLING,
TERRITORY_UNARCHIVE,
DONATE,
RECEIVE,
BUY_CREDITS,
INVITE_GIFT
}
export default async function performPaidAction (actionType, args, incomingContext) {
try {
const { me, models, forcePaymentMethod } = incomingContext
const paidAction = paidActions[actionType]
console.group('performPaidAction', actionType, args)
if (!paidAction) {
throw new Error(`Invalid action type ${actionType}`)
}
if (!me && !paidAction.anonable) {
throw new Error('You must be logged in to perform this action')
}
// treat context as immutable
const contextWithMe = {
...incomingContext,
me: me ? await models.user.findUnique({ where: { id: parseInt(me.id) } }) : undefined
}
const context = {
...contextWithMe,
cost: await paidAction.getCost(args, contextWithMe),
sybilFeePercent: await paidAction.getSybilFeePercent?.(args, contextWithMe)
}
// special case for zero cost actions
if (context.cost === 0n) {
console.log('performing zero cost action')
return await performNoInvoiceAction(actionType, args, { ...context, paymentMethod: 'ZERO_COST' })
}
for (const paymentMethod of paidAction.paymentMethods) {
console.log(`considering payment method ${paymentMethod}`)
const contextWithPaymentMethod = { ...context, paymentMethod }
if (forcePaymentMethod &&
paymentMethod !== forcePaymentMethod) {
console.log('skipping payment method', paymentMethod, 'because forcePaymentMethod is set to', forcePaymentMethod)
continue
}
// payment methods that anonymous users can use
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P) {
try {
return await performP2PAction(actionType, args, contextWithPaymentMethod)
} catch (e) {
if (e instanceof NonInvoiceablePeerError) {
console.log('peer cannot be invoiced, skipping')
continue
}
console.error(`${paymentMethod} action failed`, e)
throw e
}
} else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC) {
return await beginPessimisticAction(actionType, args, contextWithPaymentMethod)
}
// additional payment methods that logged in users can use
if (me) {
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT ||
paymentMethod === PAID_ACTION_PAYMENT_METHODS.REWARD_SATS) {
try {
return await performNoInvoiceAction(actionType, args, contextWithPaymentMethod)
} catch (e) {
// if we fail with fee credits or reward sats, but not because of insufficient funds, bail
console.error(`${paymentMethod} action failed`, e)
if (!e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"') &&
!e.message.includes('\\"users\\" violates check constraint \\"mcredits_positive\\"')) {
throw e
}
}
} else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC) {
return await performOptimisticAction(actionType, args, contextWithPaymentMethod)
} else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT) {
try {
return await performDirectAction(actionType, args, contextWithPaymentMethod)
} catch (e) {
if (e instanceof NonInvoiceablePeerError) {
console.log('peer cannot be invoiced, skipping')
continue
}
console.error(`${paymentMethod} action failed`, e)
throw e
}
}
}
}
throw new Error('No working payment method found')
} catch (e) {
console.error('performPaidAction failed', e)
throw e
} finally {
console.groupEnd()
}
}
async function performNoInvoiceAction (actionType, args, incomingContext) {
const { me, models, cost, paymentMethod } = incomingContext
const action = paidActions[actionType]
const result = await models.$transaction(async tx => {
const context = { ...incomingContext, tx }
if (paymentMethod === 'FEE_CREDIT') {
await tx.user.update({
where: {
id: me?.id ?? USER_ID.anon
},
data: { mcredits: { decrement: cost } }
})
} else if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.REWARD_SATS) {
await tx.user.update({
where: {
id: me?.id ?? USER_ID.anon
},
data: { msats: { decrement: cost } }
})
}
const result = await action.perform(args, context)
await action.onPaid?.(result, context)
return {
result,
paymentMethod
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
// run non critical side effects in the background
// after the transaction has been committed
action.nonCriticalSideEffects?.(result.result, incomingContext).catch(console.error)
return result
}
async function performOptimisticAction (actionType, args, incomingContext) {
const { models, invoiceArgs: incomingInvoiceArgs } = incomingContext
const action = paidActions[actionType]
const optimisticContext = { ...incomingContext, optimistic: true }
const invoiceArgs = incomingInvoiceArgs ?? await createSNInvoice(actionType, args, optimisticContext)
return await models.$transaction(async tx => {
const context = { ...optimisticContext, tx, invoiceArgs }
const invoice = await createDbInvoice(actionType, args, context)
return {
invoice,
result: await action.perform?.({ invoiceId: invoice.id, ...args }, context),
paymentMethod: 'OPTIMISTIC'
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
}
async function beginPessimisticAction (actionType, args, context) {
const action = paidActions[actionType]
if (!action.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC)) {
throw new Error(`This action ${actionType} does not support pessimistic invoicing`)
}
// just create the invoice and complete action when it's paid
const invoiceArgs = context.invoiceArgs ?? await createSNInvoice(actionType, args, context)
return {
invoice: await createDbInvoice(actionType, args, { ...context, invoiceArgs }),
paymentMethod: 'PESSIMISTIC'
}
}
async function performP2PAction (actionType, args, incomingContext) {
// if the action has an invoiceable peer, we'll create a peer invoice
// wrap it, and return the wrapped invoice
const { cost, sybilFeePercent, models, lnd, me } = incomingContext
if (!sybilFeePercent) {
throw new Error('sybil fee percent is not set for an invoiceable peer action')
}
const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, incomingContext)
if (!userId) {
throw new NonInvoiceablePeerError()
}
let context
try {
await assertBelowMaxPendingInvoices(incomingContext)
const description = await paidActions[actionType].describe(args, incomingContext)
const { invoice, wrappedInvoice, protocol, maxFee } = await createWrappedInvoice(userId, {
msats: cost,
feePercent: sybilFeePercent,
description,
expiry: INVOICE_EXPIRE_SECS
}, { models, me, lnd })
context = {
...incomingContext,
invoiceArgs: {
bolt11: invoice,
wrappedBolt11: wrappedInvoice,
protocol,
maxFee
}
}
} catch (e) {
console.error('failed to create wrapped invoice', e)
throw new NonInvoiceablePeerError()
}
return me
? await performOptimisticAction(actionType, args, context)
: await beginPessimisticAction(actionType, args, context)
}
// we don't need to use the module for perform-ing outside actions
// because we can't track the state of outside invoices we aren't paid/paying
async function performDirectAction (actionType, args, incomingContext) {
const { models, lnd, cost } = incomingContext
const { comment, lud18Data, noteStr, description: actionDescription } = args
const userId = await paidActions[actionType]?.getInvoiceablePeer?.(args, incomingContext)
if (!userId) {
throw new NonInvoiceablePeerError()
}
try {
await assertBelowMaxPendingDirectPayments(userId, incomingContext)
const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext)
for await (const { invoice, logger, protocol } of createUserInvoice(userId, {
msats: cost,
description,
expiry: INVOICE_EXPIRE_SECS
}, { models, lnd })) {
let hash
try {
hash = parsePaymentRequest({ request: invoice }).id
} catch (e) {
console.error('failed to parse invoice', e)
logger?.error('failed to parse invoice: ' + e.message, { bolt11: invoice })
continue
}
try {
return {
invoice: await models.directPayment.create({
data: {
comment,
lud18Data,
desc: noteStr,
bolt11: invoice,
msats: cost,
hash,
protocolId: protocol.id,
receiverId: userId
}
}),
paymentMethod: 'DIRECT'
}
} catch (e) {
console.error('failed to create direct payment', e)
logger?.error('failed to create direct payment: ' + e.message, { bolt11: invoice })
}
}
} catch (e) {
console.error('failed to create user invoice', e)
}
throw new NonInvoiceablePeerError()
}
export async function retryPaidAction (actionType, args, incomingContext) {
const { models, me } = incomingContext
const { invoice: failedInvoice } = args
console.log('retryPaidAction', actionType, args)
const action = paidActions[actionType]
if (!action) {
throw new Error(`retryPaidAction - invalid action type ${actionType}`)
}
if (!me) {
throw new Error(`retryPaidAction - must be logged in ${actionType}`)
}
if (!failedInvoice) {
throw new Error(`retryPaidAction - missing invoice ${actionType}`)
}
const { msatsRequested, actionId, actionArgs, actionOptimistic } = failedInvoice
const retryContext = {
...incomingContext,
optimistic: actionOptimistic,
me: await models.user.findUnique({ where: { id: parseInt(me.id) } }),
cost: BigInt(msatsRequested),
actionId,
predecessorId: failedInvoice.id
}
let invoiceArgs
const invoiceForward = await models.invoiceForward.findUnique({
where: {
invoiceId: failedInvoice.id
},
include: {
protocol: {
include: {
wallet: true
}
}
}
})
if (invoiceForward) {
// this is a wrapped invoice, we need to retry it with receiver fallbacks
try {
const { userId } = invoiceForward.protocol.wallet
// this will return an invoice from the first receiver wallet that didn't fail yet and throw if none is available
const { invoice: bolt11, wrappedInvoice: wrappedBolt11, protocol, maxFee } = await createWrappedInvoice(userId, {
msats: failedInvoice.msatsRequested,
feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext),
description: await action.describe?.(actionArgs, retryContext),
expiry: INVOICE_EXPIRE_SECS
}, retryContext)
invoiceArgs = { bolt11, wrappedBolt11, protocol, maxFee }
} catch (err) {
console.log('failed to retry wrapped invoice, falling back to SN:', err)
}
}
invoiceArgs ??= await createSNInvoice(actionType, actionArgs, retryContext)
return await models.$transaction(async tx => {
const context = { ...retryContext, tx, invoiceArgs }
// update the old invoice to RETRYING, so that it's not confused with FAILED
await tx.invoice.update({
where: {
id: failedInvoice.id,
actionState: 'FAILED'
},
data: {
actionState: 'RETRYING'
}
})
// create a new invoice
const invoice = await createDbInvoice(actionType, actionArgs, context)
return {
result: await action.retry?.({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),
invoice,
paymentMethod: actionOptimistic ? 'OPTIMISTIC' : 'PESSIMISTIC'
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
}
const INVOICE_EXPIRE_SECS = 600
export class NonInvoiceablePeerError extends Error {
constructor () {
super('non invoiceable peer')
this.name = 'NonInvoiceablePeerError'
}
}
// we seperate the invoice creation into two functions because
// because if lnd is slow, it'll timeout the interactive tx
async function createSNInvoice (actionType, args, context) {
const { me, lnd, cost, optimistic } = context
const action = paidActions[actionType]
const createLNDInvoice = optimistic ? createInvoice : createHodlInvoice
await assertBelowMaxPendingInvoices(context)
if (cost < 1000n) {
// sanity check
throw new Error('The cost of the action must be at least 1 sat')
}
const expiresAt = datePivot(new Date(), { seconds: INVOICE_EXPIRE_SECS })
const invoice = await createLNDInvoice({
description: me?.hideInvoiceDesc ? undefined : await action.describe(args, context),
lnd,
mtokens: String(cost),
expires_at: expiresAt
})
return { bolt11: invoice.request, preimage: invoice.secret }
}
async function createDbInvoice (actionType, args, context) {
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, paymentAttempt, predecessorId } = context
const { bolt11, wrappedBolt11, preimage, protocol, maxFee } = invoiceArgs
const db = tx ?? models
if (cost < 1000n) {
// sanity check
throw new Error('The cost of the action must be at least 1 sat')
}
const servedBolt11 = wrappedBolt11 ?? bolt11
const servedInvoice = parsePaymentRequest({ request: servedBolt11 })
const expiresAt = new Date(servedInvoice.expires_at)
const invoiceData = {
hash: servedInvoice.id,
msatsRequested: BigInt(servedInvoice.mtokens),
preimage,
bolt11: servedBolt11,
userId: me?.id ?? USER_ID.anon,
actionType,
actionState: wrappedBolt11 ? 'PENDING_HELD' : optimistic ? 'PENDING' : 'PENDING_HELD',
actionOptimistic: optimistic,
actionArgs: args,
expiresAt,
actionId,
paymentAttempt,
predecessorId
}
let invoice
if (wrappedBolt11) {
invoice = (await db.invoiceForward.create({
include: { invoice: true },
data: {
bolt11,
maxFeeMsats: maxFee,
invoice: {
create: invoiceData
},
protocol: {
connect: {
id: protocol.id
}
}
}
})).invoice
} else {
invoice = await db.invoice.create({ data: invoiceData })
}
// insert a job to check the invoice after it's set to expire
await db.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil, priority)
VALUES ('checkInvoice',
jsonb_build_object('hash', ${invoice.hash}::TEXT), 21, true,
${expiresAt}::TIMESTAMP WITH TIME ZONE,
${expiresAt}::TIMESTAMP WITH TIME ZONE + interval '10m', 100)`
// the HMAC is only returned during invoice creation
// this makes sure that only the person who created this invoice
// has access to the HMAC
invoice.hmac = createHmac(invoice.hash)
return invoice
}

View File

@ -1,60 +0,0 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { notifyInvite } from '@/lib/webPush'
export const anonable = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS
]
export async function getCost ({ id }, { models, me }) {
const invite = await models.invite.findUnique({ where: { id, userId: me.id, revoked: false } })
if (!invite) {
throw new Error('invite not found')
}
return satsToMsats(invite.gift)
}
export async function perform ({ id, userId }, { me, cost, tx }) {
const invite = await tx.invite.findUnique({
where: { id, userId: me.id, revoked: false }
})
if (invite.limit && invite.giftedCount >= invite.limit) {
throw new Error('invite limit reached')
}
// check that user was created in last hour
// check that user did not already redeem an invite
await tx.user.update({
where: {
id: userId,
inviteId: null,
createdAt: {
gt: new Date(Date.now() - 1000 * 60 * 60)
}
},
data: {
mcredits: {
increment: cost
},
inviteId: id,
referrerId: me.id
}
})
return await tx.invite.update({
where: { id, userId: me.id, revoked: false, ...(invite.limit ? { giftedCount: { lt: invite.limit } } : {}) },
data: {
giftedCount: {
increment: 1
}
}
})
}
export async function nonCriticalSideEffects (_, { me }) {
notifyInvite(me.id)
}

View File

@ -1,302 +0,0 @@
import { ANON_ITEM_SPAM_INTERVAL, ANON_FEE_MULTIPLIER, ITEM_SPAM_INTERVAL, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers, notifyThreadSubscribers } from '@/lib/webPush'
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
import { msatsToSats, satsToMsats } from '@/lib/format'
import { GqlInputError } from '@/lib/error'
import { throwOnExpiredUploads } from '@/api/resolvers/upload'
export const anonable = true
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export const DEFAULT_ITEM_COST = 1000n
export async function getBaseCost ({ models, bio, parentId, subName }) {
if (bio) return DEFAULT_ITEM_COST
if (parentId) {
// the subname is stored in the root item of the thread
const [sub] = await models.$queryRaw`
SELECT s."replyCost"
FROM "Item" i
LEFT JOIN "Item" r ON r.id = i."rootId"
LEFT JOIN "Sub" s ON s.name = COALESCE(r."subName", i."subName")
WHERE i.id = ${Number(parentId)}`
if (sub?.replyCost) return satsToMsats(sub.replyCost)
return DEFAULT_ITEM_COST
}
const sub = await models.sub.findUnique({ where: { name: subName } })
return satsToMsats(sub.baseCost)
}
export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio }, { models, me }) {
const baseCost = await getBaseCost({ models, bio, parentId, subName })
// cost = baseCost * 10^num_items_in_10m * 10 (ANON_FEE_MULTIPLIER constant) or 1 (user) + upload fees + boost
const [{ cost }] = await models.$queryRaw`
SELECT ${baseCost}::INTEGER
* POWER(10, item_spam(${parseInt(parentId)}::INTEGER, ${me?.id ?? USER_ID.anon}::INTEGER,
${me?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL))
* ${me ? 1 : ANON_FEE_MULTIPLIER}::INTEGER
+ (SELECT "nUnpaid" * "uploadFeesMsats"
FROM upload_fees(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
+ ${satsToMsats(boost)}::INTEGER as cost`
// sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon,
// cost must be greater than user's balance, and user has not disabled freebies
const freebie = (parentId || bio) && cost <= baseCost && !!me &&
me?.msats < cost && !me?.disableFreebies && me?.mcredits < cost
return freebie ? BigInt(0) : BigInt(cost)
}
export async function perform (args, context) {
const { invoiceId, parentId, uploadIds = [], forwardUsers = [], options: pollOptions = [], boost = 0, ...data } = args
const { tx, me, cost } = context
const boostMsats = satsToMsats(boost)
await throwOnExpiredUploads(uploadIds, { tx })
let invoiceData = {}
if (invoiceId) {
invoiceData = { invoiceId, invoiceActionState: 'PENDING' }
await tx.upload.updateMany({
where: { id: { in: uploadIds } },
data: invoiceData
})
}
const itemActs = []
if (boostMsats > 0) {
itemActs.push({
msats: boostMsats, act: 'BOOST', userId: data.userId, ...invoiceData
})
}
if (cost > 0) {
itemActs.push({
msats: cost - boostMsats, act: 'FEE', userId: data.userId, ...invoiceData
})
data.cost = msatsToSats(cost - boostMsats)
}
const mentions = await getMentions(args, context)
const itemMentions = await getItemMentions(args, context)
// start with median vote
if (me) {
const [row] = await tx.$queryRaw`SELECT
COALESCE(percentile_cont(0.5) WITHIN GROUP(
ORDER BY "weightedVotes" - "weightedDownVotes"), 0)
AS median FROM "Item" WHERE "userId" = ${me.id}::INTEGER`
if (row?.median < 0) {
data.weightedDownVotes = -row.median
}
}
const itemData = {
parentId: parentId ? parseInt(parentId) : null,
...data,
...invoiceData,
boost,
threadSubscriptions: {
createMany: {
data: [
{ userId: data.userId },
...forwardUsers.map(({ userId }) => ({ userId }))
]
}
},
itemForwards: {
createMany: {
data: forwardUsers
}
},
pollOptions: {
createMany: {
data: pollOptions.map(option => ({ option }))
}
},
itemUploads: {
create: uploadIds.map(id => ({ uploadId: id }))
},
itemActs: {
createMany: {
data: itemActs
}
},
mentions: {
createMany: {
data: mentions
}
},
itemReferrers: {
create: itemMentions
}
}
let item
if (data.bio && me) {
item = (await tx.user.update({
where: { id: data.userId },
include: { bio: true },
data: {
bio: {
create: itemData
}
}
})).bio
} else {
try {
item = await tx.item.create({ data: itemData })
} catch (err) {
if (err.message.includes('violates exclusion constraint \\"Item_unique_time_constraint\\"')) {
const message = `you already submitted this ${itemData.title ? 'post' : 'comment'}`
throw new GqlInputError(message)
}
throw err
}
}
// store a reference to the item in the invoice
if (invoiceId) {
await tx.invoice.update({
where: { id: invoiceId },
data: { actionId: item.id }
})
}
await performBotBehavior(item, context)
// ltree is unsupported in Prisma, so we have to query it manually (FUCK!)
return (await tx.$queryRaw`
SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt"
FROM "Item" WHERE id = ${item.id}::INTEGER`
)[0]
}
export async function retry ({ invoiceId, newInvoiceId }, { tx }) {
await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
await tx.item.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
await tx.upload.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
return (await tx.$queryRaw`
SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt"
FROM "Item" WHERE "invoiceId" = ${newInvoiceId}::INTEGER`
)[0]
}
export async function onPaid ({ invoice, id }, context) {
const { tx } = context
let item
if (invoice) {
item = await tx.item.findFirst({
where: { invoiceId: invoice.id },
include: {
user: true
}
})
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } })
await tx.item.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID', invoicePaidAt: new Date() } })
await tx.upload.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID', paid: true } })
} else if (id) {
item = await tx.item.findUnique({
where: { id },
include: {
user: true,
itemUploads: { include: { upload: true } }
}
})
await tx.upload.updateMany({
where: { id: { in: item.itemUploads.map(({ uploadId }) => uploadId) } },
data: {
paid: true
}
})
} else {
throw new Error('No item found')
}
await tx.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, priority)
VALUES ('timestampItem', jsonb_build_object('id', ${item.id}::INTEGER), now() + interval '10 minutes', -2)`
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('imgproxy', jsonb_build_object('id', ${item.id}::INTEGER), 21, true, now() + interval '5 seconds')`
if (item.boost > 0) {
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil)
VALUES ('expireBoost', jsonb_build_object('id', ${item.id}::INTEGER), 21, true,
now() + interval '30 days', now() + interval '40 days')`
}
if (item.parentId) {
// denormalize ncomments, lastCommentAt, and "weightedComments" for ancestors, and insert into reply table
await tx.$executeRaw`
WITH comment AS (
SELECT "Item".*, users.trust
FROM "Item"
JOIN users ON "Item"."userId" = users.id
WHERE "Item".id = ${item.id}::INTEGER
), ancestors AS (
SELECT "Item".*
FROM "Item", comment
WHERE "Item".path @> comment.path AND "Item".id <> comment.id
ORDER BY "Item".id
), updated_ancestors AS (
UPDATE "Item"
SET ncomments = "Item".ncomments + 1,
"lastCommentAt" = GREATEST("Item"."lastCommentAt", comment.created_at),
"nDirectComments" = "Item"."nDirectComments" +
CASE WHEN comment."parentId" = "Item".id THEN 1 ELSE 0 END
FROM comment, ancestors
WHERE "Item".id = ancestors.id
RETURNING "Item".*
)
INSERT INTO "Reply" (created_at, updated_at, "ancestorId", "ancestorUserId", "itemId", "userId", level)
SELECT comment.created_at, comment.updated_at, ancestors.id, ancestors."userId",
comment.id, comment."userId", nlevel(comment.path) - nlevel(ancestors.path)
FROM ancestors, comment`
}
}
export async function nonCriticalSideEffects ({ invoice, id }, { models }) {
const item = await models.item.findFirst({
where: invoice ? { invoiceId: invoice.id } : { id: parseInt(id) },
include: {
mentions: true,
itemReferrers: { include: { refereeItem: true } },
user: true
}
})
if (item.parentId) {
notifyItemParents({ item, models }).catch(console.error)
notifyThreadSubscribers({ models, item }).catch(console.error)
}
for (const { userId } of item.mentions) {
notifyMention({ models, item, userId }).catch(console.error)
}
for (const { refereeItem } of item.itemReferrers) {
notifyItemMention({ models, referrerItem: item, refereeItem }).catch(console.error)
}
notifyUserSubscribers({ models, item }).catch(console.error)
notifyTerritorySubscribers({ models, item }).catch(console.error)
}
export async function onFail ({ invoice }, { tx }) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
await tx.item.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
await tx.upload.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
}
export async function describe ({ parentId }, context) {
return `SN: create ${parentId ? `reply to #${parentId}` : 'item'}`
}

View File

@ -1,184 +0,0 @@
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { throwOnExpiredUploads, uploadFees } from '@/api/resolvers/upload'
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
import { notifyItemMention, notifyMention } from '@/lib/webPush'
import { satsToMsats } from '@/lib/format'
export const anonable = true
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ id, boost = 0, uploadIds, bio }, { me, models }) {
// the only reason updating items costs anything is when it has new uploads
// or more boost
const old = await models.item.findUnique({ where: { id: parseInt(id) } })
const { totalFeesMsats } = await uploadFees(uploadIds, { models, me })
const cost = totalFeesMsats + satsToMsats(boost - old.boost)
if (cost > 0 && old.invoiceActionState && old.invoiceActionState !== 'PAID') {
throw new Error('creation invoice not paid')
}
return cost
}
export async function perform (args, context) {
const { id, boost = 0, uploadIds = [], options: pollOptions = [], forwardUsers: itemForwards = [], ...data } = args
const { tx, me } = context
const old = await tx.item.findUnique({
where: { id: parseInt(id) },
include: {
threadSubscriptions: true,
mentions: true,
itemForwards: true,
itemReferrers: true,
itemUploads: true
}
})
const newBoost = boost - old.boost
const itemActs = []
if (newBoost > 0) {
const boostMsats = satsToMsats(newBoost)
itemActs.push({
msats: boostMsats, act: 'BOOST', userId: me?.id || USER_ID.anon
})
}
// createMany is the set difference of the new - old
// deleteMany is the set difference of the old - new
// updateMany is the intersection of the old and new
const difference = (a = [], b = [], key = 'userId') => a.filter(x => !b.find(y => y[key] === x[key]))
const intersectionMerge = (a = [], b = [], key) => a.filter(x => b.find(y => y.userId === x.userId))
.map(x => ({ [key]: x[key], ...b.find(y => y.userId === x.userId) }))
const mentions = await getMentions(args, context)
const itemMentions = await getItemMentions(args, context)
const itemUploads = uploadIds.map(id => ({ uploadId: id }))
await throwOnExpiredUploads(uploadIds, { tx })
await tx.upload.updateMany({
where: { id: { in: uploadIds } },
data: { paid: true }
})
// we put boost in the where clause because we don't want to update the boost
// if it has changed concurrently
await tx.item.update({
where: { id: parseInt(id), boost: old.boost },
data: {
...data,
boost: {
increment: newBoost
},
pollOptions: {
createMany: {
data: pollOptions?.map(option => ({ option }))
}
},
itemUploads: {
create: difference(itemUploads, old.itemUploads, 'uploadId').map(({ uploadId }) => ({ uploadId })),
deleteMany: {
uploadId: {
in: difference(old.itemUploads, itemUploads, 'uploadId').map(({ uploadId }) => uploadId)
}
}
},
itemActs: {
createMany: {
data: itemActs
}
},
itemForwards: {
deleteMany: {
userId: {
in: difference(old.itemForwards, itemForwards).map(({ userId }) => userId)
}
},
createMany: {
data: difference(itemForwards, old.itemForwards)
},
update: intersectionMerge(old.itemForwards, itemForwards, 'id').map(({ id, ...data }) => ({
where: { id },
data
}))
},
threadSubscriptions: {
deleteMany: {
userId: {
in: difference(old.itemForwards, itemForwards).map(({ userId }) => userId)
}
},
createMany: {
data: difference(itemForwards, old.itemForwards).map(({ userId }) => ({ userId }))
}
},
mentions: {
deleteMany: {
userId: {
in: difference(old.mentions, mentions).map(({ userId }) => userId)
}
},
createMany: {
data: difference(mentions, old.mentions)
}
},
itemReferrers: {
deleteMany: {
refereeId: {
in: difference(old.itemReferrers, itemMentions, 'refereeId').map(({ refereeId }) => refereeId)
}
},
create: difference(itemMentions, old.itemReferrers, 'refereeId')
}
}
})
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil)
VALUES ('imgproxy', jsonb_build_object('id', ${id}::INTEGER), 21, true,
now() + interval '5 seconds', now() + interval '1 day')`
if (newBoost > 0) {
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter, keepuntil)
VALUES ('expireBoost', jsonb_build_object('id', ${id}::INTEGER), 21, true,
now() + interval '30 days', now() + interval '40 days')`
}
await performBotBehavior(args, context)
// ltree is unsupported in Prisma, so we have to query it manually (FUCK!)
return (await tx.$queryRaw`
SELECT *, ltree2text(path) AS path, created_at AS "createdAt", updated_at AS "updatedAt"
FROM "Item" WHERE id = ${parseInt(id)}::INTEGER`
)[0]
}
export async function nonCriticalSideEffects ({ invoice, id }, { models }) {
const item = await models.item.findFirst({
where: invoice ? { invoiceId: invoice.id } : { id: parseInt(id) },
include: {
mentions: true,
itemReferrers: { include: { refereeItem: true } },
user: true
}
})
// compare timestamps to only notify if mention or item referral was just created to avoid duplicates on edits
for (const { userId, createdAt } of item.mentions) {
if (item.updatedAt.getTime() !== createdAt.getTime()) continue
notifyMention({ models, item, userId }).catch(console.error)
}
for (const { refereeItem, createdAt } of item.itemReferrers) {
if (item.updatedAt.getTime() !== createdAt.getTime()) continue
notifyItemMention({ models, referrerItem: item, refereeItem }).catch(console.error)
}
}
export async function describe ({ id, parentId }, context) {
return `SN: update ${parentId ? `reply to #${parentId}` : 'post'}`
}

View File

@ -1,56 +0,0 @@
import { PAID_ACTION_TERMINAL_STATES, USER_ID } from '@/lib/constants'
import { datePivot } from '@/lib/time'
const MAX_PENDING_PAID_ACTIONS_PER_USER = 100
const MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES = 10
const MAX_PENDING_DIRECT_INVOICES_PER_USER = 100
export async function assertBelowMaxPendingInvoices (context) {
const { models, me } = context
const pendingInvoices = await models.invoice.count({
where: {
userId: me?.id ?? USER_ID.anon,
actionState: {
notIn: PAID_ACTION_TERMINAL_STATES
}
}
})
if (pendingInvoices >= MAX_PENDING_PAID_ACTIONS_PER_USER) {
throw new Error('You have too many pending paid actions, cancel some or wait for them to expire')
}
}
export async function assertBelowMaxPendingDirectPayments (userId, context) {
const { models, me } = context
if (me?.id !== userId) {
const pendingSenderInvoices = await models.directPayment.count({
where: {
senderId: me?.id ?? USER_ID.anon,
createdAt: {
gt: datePivot(new Date(), { minutes: -MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES })
}
}
})
if (pendingSenderInvoices >= MAX_PENDING_DIRECT_INVOICES_PER_USER) {
throw new Error('You\'ve sent too many direct payments')
}
}
if (!userId) return
const pendingReceiverInvoices = await models.directPayment.count({
where: {
receiverId: userId,
createdAt: {
gt: datePivot(new Date(), { minutes: -MAX_PENDING_DIRECT_INVOICES_PER_USER_MINUTES })
}
}
})
if (pendingReceiverInvoices >= MAX_PENDING_DIRECT_INVOICES_PER_USER) {
throw new Error('Receiver has too many direct payments')
}
}

View File

@ -1,89 +0,0 @@
import { USER_ID } from '@/lib/constants'
import { deleteReminders, getDeleteAt, getRemindAt } from '@/lib/item'
import { parseInternalLinks } from '@/lib/url'
export async function getMentions ({ text }, { me, tx }) {
const mentionPattern = /\B@[\w_]+/gi
const names = text.match(mentionPattern)?.map(m => m.slice(1))
if (names?.length > 0) {
const users = await tx.user.findMany({
where: {
name: {
in: names
},
id: {
not: me?.id || USER_ID.anon
}
}
})
return users.map(user => ({ userId: user.id }))
}
return []
}
export const getItemMentions = async ({ text }, { me, tx }) => {
const linkPattern = new RegExp(`${process.env.NEXT_PUBLIC_URL}/items/\\d+[a-zA-Z0-9/?=]*`, 'gi')
const refs = text.match(linkPattern)?.map(m => {
try {
const { itemId, commentId } = parseInternalLinks(m)
return Number(commentId || itemId)
} catch (err) {
return null
}
}).filter(r => !!r)
if (refs?.length > 0) {
const referee = await tx.item.findMany({
where: {
id: { in: refs },
userId: { not: me?.id || USER_ID.anon }
}
})
return referee.map(r => ({ refereeId: r.id }))
}
return []
}
export async function performBotBehavior ({ text, id }, { me, tx }) {
// delete any existing deleteItem or reminder jobs for this item
const userId = me?.id || USER_ID.anon
id = Number(id)
await tx.$queryRaw`
DELETE FROM pgboss.job
WHERE name = 'deleteItem'
AND data->>'id' = ${id}::TEXT
AND state <> 'completed'`
await deleteReminders({ id, userId, models: tx })
if (text) {
const deleteAt = getDeleteAt(text)
if (deleteAt) {
await tx.$queryRaw`
INSERT INTO pgboss.job (name, data, startafter, keepuntil)
VALUES (
'deleteItem',
jsonb_build_object('id', ${id}::INTEGER),
${deleteAt}::TIMESTAMP WITH TIME ZONE,
${deleteAt}::TIMESTAMP WITH TIME ZONE + interval '1 minute')`
}
const remindAt = getRemindAt(text)
if (remindAt) {
await tx.$queryRaw`
INSERT INTO pgboss.job (name, data, startafter, keepuntil)
VALUES (
'reminder',
jsonb_build_object('itemId', ${id}::INTEGER, 'userId', ${userId}::INTEGER),
${remindAt}::TIMESTAMP WITH TIME ZONE,
${remindAt}::TIMESTAMP WITH TIME ZONE + interval '1 minute')`
await tx.reminder.create({
data: {
userId,
itemId: Number(id),
remindAt
}
})
}
}
}

View File

@ -1,27 +0,0 @@
import { USER_ID } from '@/lib/constants'
export const GLOBAL_SEEDS = [USER_ID.k00b, USER_ID.ek]
export function initialTrust ({ name, userId }) {
const results = GLOBAL_SEEDS.map(id => ({
subName: name,
userId: id,
zapPostTrust: 1,
subZapPostTrust: 1,
zapCommentTrust: 1,
subZapCommentTrust: 1
}))
if (!GLOBAL_SEEDS.includes(userId)) {
results.push({
subName: name,
userId,
zapPostTrust: 0,
subZapPostTrust: 1,
zapCommentTrust: 0,
subZapCommentTrust: 1
})
}
return results
}

View File

@ -1,70 +0,0 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
export const anonable = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC
]
export async function getCost ({ id }, { me, models }) {
const pollOption = await models.pollOption.findUnique({
where: { id: parseInt(id) },
include: { item: true }
})
return satsToMsats(pollOption.item.pollCost)
}
export async function perform ({ invoiceId, id }, { me, cost, tx }) {
const pollOption = await tx.pollOption.findUnique({
where: { id: parseInt(id) }
})
const itemId = parseInt(pollOption.itemId)
let invoiceData = {}
if (invoiceId) {
invoiceData = { invoiceId, invoiceActionState: 'PENDING' }
// store a reference to the item in the invoice
await tx.invoice.update({
where: { id: invoiceId },
data: { actionId: itemId }
})
}
// the unique index on userId, itemId will prevent double voting
await tx.itemAct.create({ data: { msats: cost, itemId, userId: me.id, act: 'POLL', ...invoiceData } })
await tx.pollBlindVote.create({ data: { userId: me.id, itemId, ...invoiceData } })
await tx.pollVote.create({ data: { pollOptionId: pollOption.id, itemId, ...invoiceData } })
return { id }
}
export async function retry ({ invoiceId, newInvoiceId }, { tx }) {
await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
await tx.pollBlindVote.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
await tx.pollVote.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
const { pollOptionId } = await tx.pollVote.findFirst({ where: { invoiceId: newInvoiceId } })
return { id: pollOptionId }
}
export async function onPaid ({ invoice }, { tx }) {
if (!invoice) return
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } })
await tx.pollBlindVote.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } })
// anonymize the vote
await tx.pollVote.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceId: null, invoiceActionState: null } })
}
export async function onFail ({ invoice }, { tx }) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
await tx.pollBlindVote.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
await tx.pollVote.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
}
export async function describe ({ id }, { actionId }) {
return `SN: vote on poll #${id ?? actionId}`
}

View File

@ -1,64 +0,0 @@
import { PAID_ACTION_PAYMENT_METHODS, PROXY_RECEIVE_FEE_PERCENT } from '@/lib/constants'
import { toPositiveBigInt, numWithUnits, msatsToSats } from '@/lib/format'
import { notifyDeposit } from '@/lib/webPush'
import { getInvoiceableWallets } from '@/wallets/server'
export const anonable = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.P2P,
PAID_ACTION_PAYMENT_METHODS.DIRECT
]
export async function getCost ({ msats }) {
return toPositiveBigInt(msats)
}
export async function getInvoiceablePeer (_, { me, models, cost, paymentMethod }) {
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P && !me?.proxyReceive) return null
// don't fallback to direct if proxy is enabled to always hide stacker's node pubkey
if (paymentMethod === PAID_ACTION_PAYMENT_METHODS.DIRECT && me?.proxyReceive) return null
const wallets = await getInvoiceableWallets(me.id, { models })
if (wallets.length === 0) {
return null
}
return me.id
}
export async function getSybilFeePercent () {
return PROXY_RECEIVE_FEE_PERCENT
}
export async function perform ({
invoiceId,
comment,
lud18Data,
noteStr
}, { me, tx }) {
return await tx.invoice.update({
where: { id: invoiceId },
data: {
comment,
lud18Data,
...(noteStr ? { desc: noteStr } : {})
},
include: { invoiceForward: true }
})
}
export async function describe ({ description }, { me, cost, paymentMethod, sybilFeePercent }) {
const fee = paymentMethod === PAID_ACTION_PAYMENT_METHODS.P2P
? cost * BigInt(sybilFeePercent) / 100n
: 0n
return description ?? `SN: ${me?.name ?? ''} receives ${numWithUnits(msatsToSats(cost - fee))}`
}
export async function nonCriticalSideEffects ({ invoice }, { models }) {
await notifyDeposit(invoice.userId, invoice)
await models.$executeRaw`
INSERT INTO pgboss.job (name, data)
VALUES ('nip57', jsonb_build_object('hash', ${invoice.hash}))`
}

View File

@ -1,73 +0,0 @@
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { nextBilling } from '@/lib/territory'
export const anonable = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ name }, { models }) {
const sub = await models.sub.findUnique({
where: {
name
}
})
return satsToMsats(TERRITORY_PERIOD_COST(sub.billingType))
}
export async function perform ({ name }, { cost, tx }) {
const sub = await tx.sub.findUnique({
where: {
name
}
})
if (sub.billingType === 'ONCE') {
throw new Error('Cannot bill a ONCE territory')
}
let billedLastAt = sub.billPaidUntil
let billingCost = sub.billingCost
// if the sub is archived, they are paying to reactivate it
if (sub.status === 'STOPPED') {
// get non-grandfathered cost and reset their billing to start now
billedLastAt = new Date()
billingCost = TERRITORY_PERIOD_COST(sub.billingType)
}
const billPaidUntil = nextBilling(billedLastAt, sub.billingType)
return await tx.sub.update({
// optimistic concurrency control
// make sure the sub hasn't changed since we fetched it
where: {
...sub,
postTypes: {
equals: sub.postTypes
}
},
data: {
billedLastAt,
billPaidUntil,
billingCost,
status: 'ACTIVE',
SubAct: {
create: {
msats: cost,
type: 'BILLING',
userId: sub.userId
}
}
}
})
}
export async function describe ({ name }) {
return `SN: billing for territory ${name}`
}

View File

@ -1,71 +0,0 @@
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { nextBilling } from '@/lib/territory'
import { initialTrust } from './lib/territory'
import { throwOnExpiredUploads, uploadFees } from '@/api/resolvers/upload'
export const anonable = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ billingType, uploadIds }, { models, me }) {
const { totalFees } = await uploadFees(uploadIds, { models, me })
return satsToMsats(BigInt(TERRITORY_PERIOD_COST(billingType)) + totalFees)
}
export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
const { billingType } = data
const billingCost = TERRITORY_PERIOD_COST(billingType)
const billedLastAt = new Date()
const billPaidUntil = nextBilling(billedLastAt, billingType)
await throwOnExpiredUploads(data.uploadIds, { tx })
if (data.uploadIds.length > 0) {
await tx.upload.updateMany({
where: {
id: { in: data.uploadIds }
},
data: {
paid: true
}
})
}
delete data.uploadIds
const sub = await tx.sub.create({
data: {
...data,
billedLastAt,
billPaidUntil,
billingCost,
rankingType: 'WOT',
userId: me.id,
SubAct: {
create: {
msats: cost,
type: 'BILLING',
userId: me.id
}
},
SubSubscription: {
create: {
userId: me.id
}
}
}
})
await tx.userSubTrust.createMany({
data: initialTrust({ name: sub.name, userId: sub.userId })
})
return sub
}
export async function describe ({ name }) {
return `SN: create territory ${name}`
}

View File

@ -1,104 +0,0 @@
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { nextBilling } from '@/lib/territory'
import { initialTrust } from './lib/territory'
export const anonable = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ billingType }) {
return satsToMsats(TERRITORY_PERIOD_COST(billingType))
}
export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
const sub = await tx.sub.findUnique({
where: {
name
}
})
data.billingCost = TERRITORY_PERIOD_COST(data.billingType)
// we never want to bill them again if they are changing to ONCE
if (data.billingType === 'ONCE') {
data.billPaidUntil = null
data.billingAutoRenew = false
}
data.billedLastAt = new Date()
data.billPaidUntil = nextBilling(data.billedLastAt, data.billingType)
data.status = 'ACTIVE'
data.userId = me.id
if (sub.userId !== me.id) {
try {
// XXX this will throw if this transfer has already happened
// TODO: upsert this
await tx.territoryTransfer.create({ data: { subName: name, oldUserId: sub.userId, newUserId: me.id } })
// this will throw if the prior user has already unsubscribed
await tx.subSubscription.delete({ where: { userId_subName: { userId: sub.userId, subName: name } } })
} catch (e) {
console.error(e)
}
}
await tx.subAct.create({
data: {
userId: me.id,
subName: name,
msats: cost,
type: 'BILLING'
}
})
await tx.subSubscription.upsert({
where: {
userId_subName: {
userId: me.id,
subName: name
}
},
update: {
userId: me.id,
subName: name
},
create: {
userId: me.id,
subName: name
}
})
const updatedSub = await tx.sub.update({
data,
// optimistic concurrency control
// make sure none of the relevant fields have changed since we fetched the sub
where: {
...sub,
postTypes: {
equals: sub.postTypes
}
}
})
const trust = initialTrust({ name: updatedSub.name, userId: updatedSub.userId })
for (const t of trust) {
await tx.userSubTrust.upsert({
where: {
userId_subName: { userId: t.userId, subName: t.subName }
},
update: t,
create: t
})
}
return updatedSub
}
export async function describe ({ name }, context) {
return `SN: unarchive territory ${name}`
}

View File

@ -1,95 +0,0 @@
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { proratedBillingCost } from '@/lib/territory'
import { datePivot } from '@/lib/time'
import { throwOnExpiredUploads, uploadFees } from '@/api/resolvers/upload'
export const anonable = false
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ oldName, billingType, uploadIds }, { models, me }) {
const oldSub = await models.sub.findUnique({
where: {
name: oldName
}
})
const { totalFees } = await uploadFees(uploadIds, { models, me })
const cost = BigInt(proratedBillingCost(oldSub, billingType)) + totalFees
return satsToMsats(cost)
}
export async function perform ({ oldName, invoiceId, ...data }, { me, cost, tx }) {
const oldSub = await tx.sub.findUnique({
where: {
name: oldName
}
})
data.billingCost = TERRITORY_PERIOD_COST(data.billingType)
// we never want to bill them again if they are changing to ONCE
if (data.billingType === 'ONCE') {
data.billPaidUntil = null
data.billingAutoRenew = false
}
// if they are changing to YEARLY, bill them in a year
// if they are changing to MONTHLY from YEARLY, do nothing
if (oldSub.billingType === 'MONTHLY' && data.billingType === 'YEARLY') {
data.billPaidUntil = datePivot(new Date(oldSub.billedLastAt), { years: 1 })
}
// if this billing change makes their bill paid up, set them to active
if (data.billPaidUntil === null || data.billPaidUntil >= new Date()) {
data.status = 'ACTIVE'
}
if (cost > 0n) {
await tx.subAct.create({
data: {
userId: me.id,
subName: oldName,
msats: cost,
type: 'BILLING'
}
})
}
await throwOnExpiredUploads(data.uploadIds, { tx })
if (data.uploadIds.length > 0) {
await tx.upload.updateMany({
where: {
id: { in: data.uploadIds }
},
data: {
paid: true
}
})
}
delete data.uploadIds
return await tx.sub.update({
data,
where: {
// optimistic concurrency control
// make sure none of the relevant fields have changed since we fetched the sub
...oldSub,
postTypes: {
equals: oldSub.postTypes
},
name: oldName,
userId: me.id
}
})
}
export async function describe ({ name }, context) {
return `SN: update territory billing ${name}`
}

View File

@ -1,245 +0,0 @@
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { msatsToSats, satsToMsats } from '@/lib/format'
import { notifyZapped } from '@/lib/webPush'
import { getInvoiceableWallets } from '@/wallets/server'
import { Prisma } from '@prisma/client'
export const anonable = true
export const paymentMethods = [
PAID_ACTION_PAYMENT_METHODS.P2P,
PAID_ACTION_PAYMENT_METHODS.FEE_CREDIT,
PAID_ACTION_PAYMENT_METHODS.REWARD_SATS,
PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC,
PAID_ACTION_PAYMENT_METHODS.PESSIMISTIC
]
export async function getCost ({ sats }) {
return satsToMsats(sats)
}
export async function getInvoiceablePeer ({ id, sats, hasSendWallet }, { models, me, cost }) {
// if the zap is dust, or if me doesn't have a send wallet but has enough sats/credits to pay for it
// then we don't invoice the peer
if (sats < me?.sendCreditsBelowSats ||
(me && !hasSendWallet && (me.mcredits >= cost || me.msats >= cost))) {
return null
}
const item = await models.item.findUnique({
where: { id: parseInt(id) },
include: {
itemForwards: true,
user: true
}
})
// bios don't get sats
if (item.bio) {
return null
}
const protocols = await getInvoiceableWallets(item.userId, { models })
// request peer invoice if they have an attached wallet and have not forwarded the item
// and the receiver doesn't want to receive credits
if (protocols.length > 0 &&
item.itemForwards.length === 0 &&
sats >= item.user.receiveCreditsBelowSats) {
return item.userId
}
return null
}
export async function getSybilFeePercent () {
return 30n
}
export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, cost, sybilFeePercent, tx }) {
const feeMsats = cost * sybilFeePercent / 100n
const zapMsats = cost - feeMsats
itemId = parseInt(itemId)
let invoiceData = {}
if (invoiceId) {
invoiceData = { invoiceId, invoiceActionState: 'PENDING' }
// store a reference to the item in the invoice
await tx.invoice.update({
where: { id: invoiceId },
data: { actionId: itemId }
})
}
const acts = await tx.itemAct.createManyAndReturn({
data: [
{ msats: feeMsats, itemId, userId: me?.id ?? USER_ID.anon, act: 'FEE', ...invoiceData },
{ msats: zapMsats, itemId, userId: me?.id ?? USER_ID.anon, act: 'TIP', ...invoiceData }
]
})
const [{ path }] = await tx.$queryRaw`
SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER`
return { id: itemId, sats, act: 'TIP', path, actIds: acts.map(act => act.id) }
}
export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) {
await tx.itemAct.updateMany({ where: { invoiceId }, data: { invoiceId: newInvoiceId, invoiceActionState: 'PENDING' } })
const [{ id, path }] = await tx.$queryRaw`
SELECT "Item".id, ltree2text(path) as path
FROM "Item"
JOIN "ItemAct" ON "Item".id = "ItemAct"."itemId"
WHERE "ItemAct"."invoiceId" = ${newInvoiceId}::INTEGER`
return { id, sats: msatsToSats(cost), act: 'TIP', path }
}
export async function onPaid ({ invoice, actIds }, { tx }) {
let acts
if (invoice) {
await tx.itemAct.updateMany({
where: { invoiceId: invoice.id },
data: {
invoiceActionState: 'PAID'
}
})
acts = await tx.itemAct.findMany({ where: { invoiceId: invoice.id }, include: { item: true } })
actIds = acts.map(act => act.id)
} else if (actIds) {
acts = await tx.itemAct.findMany({ where: { id: { in: actIds } }, include: { item: true } })
} else {
throw new Error('No invoice or actIds')
}
const msats = acts.reduce((a, b) => a + BigInt(b.msats), BigInt(0))
const sats = msatsToSats(msats)
const itemAct = acts.find(act => act.act === 'TIP')
if (invoice?.invoiceForward) {
// only the op got sats and we need to add it to their stackedMsats
// because the sats were p2p
await tx.user.update({
where: { id: itemAct.item.userId },
data: { stackedMsats: { increment: itemAct.msats } }
})
} else {
// splits only use mcredits
await tx.$executeRaw`
WITH forwardees AS (
SELECT "userId", ((${itemAct.msats}::BIGINT * pct) / 100)::BIGINT AS mcredits
FROM "ItemForward"
WHERE "itemId" = ${itemAct.itemId}::INTEGER
), total_forwarded AS (
SELECT COALESCE(SUM(mcredits), 0) as mcredits
FROM forwardees
), recipients AS (
SELECT "userId", mcredits FROM forwardees
UNION
SELECT ${itemAct.item.userId}::INTEGER as "userId",
${itemAct.msats}::BIGINT - (SELECT mcredits FROM total_forwarded)::BIGINT as mcredits
ORDER BY "userId" ASC -- order to prevent deadlocks
)
UPDATE users
SET
mcredits = users.mcredits + recipients.mcredits,
"stackedMsats" = users."stackedMsats" + recipients.mcredits,
"stackedMcredits" = users."stackedMcredits" + recipients.mcredits
FROM recipients
WHERE users.id = recipients."userId"`
}
// perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking
await tx.$queryRaw`
WITH territory AS (
SELECT COALESCE(r."subName", i."subName", 'meta')::CITEXT as "subName"
FROM "Item" i
LEFT JOIN "Item" r ON r.id = i."rootId"
WHERE i.id = ${itemAct.itemId}::INTEGER
), zapper AS (
SELECT
COALESCE(${itemAct.item.parentId
? Prisma.sql`"zapCommentTrust"`
: Prisma.sql`"zapPostTrust"`}, 0) as "zapTrust",
COALESCE(${itemAct.item.parentId
? Prisma.sql`"subZapCommentTrust"`
: Prisma.sql`"subZapPostTrust"`}, 0) as "subZapTrust"
FROM territory
LEFT JOIN "UserSubTrust" ust ON ust."subName" = territory."subName"
AND ust."userId" = ${itemAct.userId}::INTEGER
), zap AS (
INSERT INTO "ItemUserAgg" ("userId", "itemId", "zapSats")
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
ON CONFLICT ("itemId", "userId") DO UPDATE
SET "zapSats" = "ItemUserAgg"."zapSats" + ${sats}::INTEGER, updated_at = now()
RETURNING ("zapSats" = ${sats}::INTEGER)::INTEGER as first_vote,
LOG("zapSats" / GREATEST("zapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
), item_zapped AS (
UPDATE "Item"
SET
"weightedVotes" = "weightedVotes" + zapper."zapTrust" * zap.log_sats,
"subWeightedVotes" = "subWeightedVotes" + zapper."subZapTrust" * zap.log_sats,
upvotes = upvotes + zap.first_vote,
msats = "Item".msats + ${msats}::BIGINT,
mcredits = "Item".mcredits + ${invoice?.invoiceForward ? 0n : msats}::BIGINT,
"lastZapAt" = now()
FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER
RETURNING "Item".*, zapper."zapTrust" * zap.log_sats as "weightedVote"
), ancestors AS (
SELECT "Item".*
FROM "Item", item_zapped
WHERE "Item".path @> item_zapped.path AND "Item".id <> item_zapped.id
ORDER BY "Item".id
)
UPDATE "Item"
SET "weightedComments" = "Item"."weightedComments" + item_zapped."weightedVote",
"commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT,
"commentMcredits" = "Item"."commentMcredits" + ${invoice?.invoiceForward ? 0n : msats}::BIGINT
FROM item_zapped, ancestors
WHERE "Item".id = ancestors.id`
// record potential bounty payment
// NOTE: we are at least guaranteed that we see the update "ItemUserAgg" from our tx so we can trust
// we won't miss a zap that aggregates into a bounty payment, regardless of the order of updates
await tx.$executeRaw`
WITH bounty AS (
SELECT root.id, "ItemUserAgg"."zapSats" >= root.bounty AS paid, "ItemUserAgg"."itemId" AS target
FROM "ItemUserAgg"
JOIN "Item" ON "Item".id = "ItemUserAgg"."itemId"
LEFT JOIN "Item" root ON root.id = "Item"."rootId"
WHERE "ItemUserAgg"."userId" = ${itemAct.userId}::INTEGER
AND "ItemUserAgg"."itemId" = ${itemAct.itemId}::INTEGER
AND root."userId" = ${itemAct.userId}::INTEGER
AND root.bounty IS NOT NULL
)
UPDATE "Item"
SET "bountyPaidTo" = array_remove(array_append(array_remove("bountyPaidTo", bounty.target), bounty.target), NULL)
FROM bounty
WHERE "Item".id = bounty.id AND bounty.paid`
}
export async function nonCriticalSideEffects ({ invoice, actIds }, { models }) {
const itemAct = await models.itemAct.findFirst({
where: invoice ? { invoiceId: invoice.id } : { id: { in: actIds } },
include: { item: true }
})
// avoid duplicate notifications with the same zap amount
// by checking if there are any other pending acts on the item
const pendingActs = await models.itemAct.count({
where: {
itemId: itemAct.itemId,
createdAt: {
gt: itemAct.createdAt
}
}
})
if (pendingActs === 0) notifyZapped({ models, item: itemAct.item }).catch(console.error)
}
export async function onFail ({ invoice }, { tx }) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'FAILED' } })
}
export async function describe ({ id: itemId, sats }, { actionId, cost }) {
return `SN: zap ${sats ?? msatsToSats(cost)} sats to #${itemId ?? actionId}`
}

View File

@ -1,64 +0,0 @@
import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants'
import { msatsToSats, satsToMsats, toPositiveBigInt } from '@/lib/format'
import { Prisma } from '@prisma/client'
import { parsePaymentRequest, payViaPaymentRequest } from 'ln-service'
// paying actions are completely distinct from paid actions
// and there's only one paying action: send
// ... still we want the api to at least be similar
export default async function performPayingAction ({ bolt11, maxFee, protocolId }, { me, models, lnd }) {
try {
console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, protocolId)
if (!me) {
throw new Error('You must be logged in to perform this action')
}
const decoded = await parsePaymentRequest({ request: bolt11 })
const cost = toPositiveBigInt(toPositiveBigInt(decoded.mtokens) + satsToMsats(maxFee))
console.log('cost', cost)
const withdrawal = await models.$transaction(async tx => {
await tx.user.update({
where: {
id: me.id
},
data: { msats: { decrement: cost } }
})
return await tx.withdrawl.create({
data: {
hash: decoded.id,
bolt11,
msatsPaying: toPositiveBigInt(decoded.mtokens),
msatsFeePaying: satsToMsats(maxFee),
userId: me.id,
protocolId,
autoWithdraw: !!protocolId
}
})
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
payViaPaymentRequest({
lnd,
request: withdrawal.bolt11,
max_fee: msatsToSats(withdrawal.msatsFeePaying),
pathfinding_timeout: LND_PATHFINDING_TIMEOUT_MS,
confidence: LND_PATHFINDING_TIME_PREF_PPM
}).catch(console.error)
return withdrawal
} catch (e) {
if (e.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) {
throw new Error('insufficient funds')
}
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new Error('you cannot withdraw to the same invoice twice')
}
console.error('performPayingAction failed', e)
throw e
} finally {
console.groupEnd()
}
}

View File

@ -1,5 +1,3 @@
import { SN_ADMIN_IDS } from '@/lib/constants'
export default {
Query: {
snl: async (parent, _, { models }) => {
@ -9,7 +7,7 @@ export default {
},
Mutation: {
onAirToggle: async (parent, _, { models, me }) => {
if (!me || !SN_ADMIN_IDS.includes(me.id)) {
if (me.id !== 616) {
throw new Error('not an admin')
}
const { id, live } = await models.snl.findFirst()

View File

@ -1,7 +1,7 @@
import { GqlAuthorizationError } from '@/lib/error'
import { GraphQLError } from 'graphql'
export default function assertApiKeyNotPermitted ({ me }) {
if (me?.apiKey === true) {
throw new GqlAuthorizationError('this operation is not allowed to be performed via API Key')
throw new GraphQLError('this operation is not allowed to be performed via API Key', { extensions: { code: 'FORBIDDEN' } })
}
}

View File

@ -1,27 +1,37 @@
import { isServiceEnabled } from '@/lib/sndev'
import { cachedFetcher } from '@/lib/fetch'
import { getHeight } from 'ln-service'
import lndService from 'ln-service'
import lnd from '@/api/lnd'
const getBlockHeight = cachedFetcher(async function fetchBlockHeight ({ lnd }) {
const cache = new Map()
const expiresIn = 1000 * 30 // 30 seconds in milliseconds
async function fetchBlockHeight () {
let blockHeight = 0
try {
const { current_block_height: height } = await getHeight({ lnd })
return height
const height = await lndService.getHeight({ lnd })
blockHeight = height.current_block_height
} catch (err) {
console.error('getBlockHeight', err)
return 0
console.error('fetchBlockHeight', err)
}
}, {
maxSize: 1,
cacheExpiry: 60 * 1000, // 1 minute
forceRefreshThreshold: 0,
keyGenerator: () => 'getBlockHeight'
})
cache.set('block', { height: blockHeight, createdAt: Date.now() })
return blockHeight
}
async function getBlockHeight () {
if (cache.has('block')) {
const { height, createdAt } = cache.get('block')
const expired = createdAt + expiresIn < Date.now()
if (expired) fetchBlockHeight().catch(console.error) // update cache
return height // serve stale block height (this on the SSR critical path)
} else {
fetchBlockHeight().catch(console.error)
}
return 0
}
export default {
Query: {
blockHeight: async (parent, opts, { lnd }) => {
if (!isServiceEnabled('payments')) return 0
return await getBlockHeight({ lnd }) || 0
blockHeight: async (parent, opts, ctx) => {
return await getBlockHeight()
}
}
}

View File

@ -1,26 +1,36 @@
import { cachedFetcher } from '@/lib/fetch'
const cache = new Map()
const expiresIn = 1000 * 30 // 30 seconds in milliseconds
const getChainFeeRate = cachedFetcher(async function fetchChainFeeRate () {
async function fetchChainFeeRate () {
const url = 'https://mempool.space/api/v1/fees/recommended'
try {
const res = await fetch(url)
const body = await res.json()
return body.hourFee
} catch (err) {
console.error('fetchChainFee', err)
return 0
const chainFee = await fetch(url)
.then((res) => res.json())
.then((body) => body.hourFee)
.catch((err) => {
console.error('fetchChainFee', err)
return 0
})
cache.set('fee', { fee: chainFee, createdAt: Date.now() })
return chainFee
}
async function getChainFeeRate () {
if (cache.has('fee')) {
const { fee, createdAt } = cache.get('fee')
const expired = createdAt + expiresIn < Date.now()
if (expired) fetchChainFeeRate().catch(console.error) // update cache
return fee
} else {
fetchChainFeeRate().catch(console.error)
}
}, {
maxSize: 1,
cacheExpiry: 60 * 1000, // 1 minute
forceRefreshThreshold: 0, // never force refresh
keyGenerator: () => 'getChainFeeRate'
})
return 0
}
export default {
Query: {
chainFee: async (parent, opts, ctx) => {
return await getChainFeeRate() || 0
return await getChainFeeRate()
}
}
}

View File

@ -1,5 +1,26 @@
import { timeUnitForRange, whenRange } from '@/lib/time'
export function withClause (range) {
const unit = timeUnitForRange(range)
return `
WITH range_values AS (
SELECT date_trunc('${unit}', $1) as minval,
date_trunc('${unit}', $2) as maxval
),
times AS (
SELECT generate_series(minval, maxval, interval '1 ${unit}') as time
FROM range_values
)
`
}
export function intervalClause (range, table) {
const unit = timeUnitForRange(range)
return `date_trunc('${unit}', "${table}".created_at) >= date_trunc('${unit}', $1) AND date_trunc('${unit}', "${table}".created_at) <= date_trunc('${unit}', $2) `
}
export function viewIntervalClause (range, view) {
const unit = timeUnitForRange(range)
return `"${view}".t >= date_trunc('${unit}', timezone('America/Chicago', $1)) AND date_trunc('${unit}', "${view}".t) <= date_trunc('${unit}', timezone('America/Chicago', $2)) `
@ -21,11 +42,31 @@ export function viewGroup (range, view) {
${view}(
date_trunc('hour', timezone('America/Chicago', now())),
date_trunc('hour', timezone('America/Chicago', now())), '1 hour'::INTERVAL, 'hour')
WHERE "${view}".t >= date_trunc('hour', timezone('America/Chicago', $1))
AND "${view}".t <= date_trunc('hour', timezone('America/Chicago', $2)))
WHERE "${view}".t >= date_trunc('${unit}', timezone('America/Chicago', $1))
AND "${view}".t <= date_trunc('${unit}', timezone('America/Chicago', $2)))
) u`
}
export function subViewGroup (range) {
const unit = timeUnitForRange(range)
return `(
(SELECT *
FROM sub_stats_days
WHERE ${viewIntervalClause(range, 'sub_stats_days')})
UNION ALL
(SELECT *
FROM sub_stats_hours
WHERE ${viewIntervalClause(range, 'sub_stats_hours')}
${unit === 'hour' ? '' : 'AND "sub_stats_hours".t >= date_trunc(\'day\', timezone(\'America/Chicago\', now()))'})
UNION ALL
(SELECT * FROM
sub_stats(
date_trunc('hour', timezone('America/Chicago', now())),
date_trunc('hour', timezone('America/Chicago', now())), '1 hour'::INTERVAL, 'hour')
WHERE "sub_stats".t >= date_trunc('${unit}', timezone('America/Chicago', $1)))
)`
}
export default {
Query: {
registrationGrowth: async (parent, { when, from, to }, { models }) => {
@ -121,39 +162,6 @@ export default {
FROM ${viewGroup(range, 'stacking_growth')}
GROUP BY time
ORDER BY time ASC`, ...range)
},
itemGrowthSubs: async (parent, { when, to, from, sub }, { models }) => {
const range = whenRange(when, from, to)
const subExists = await models.sub.findUnique({ where: { name: sub } })
if (!subExists) throw new Error('Sub not found')
return await models.$queryRawUnsafe(`
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time, json_build_array(
json_build_object('name', 'posts', 'value', coalesce(sum(posts),0)),
json_build_object('name', 'comments', 'value', coalesce(sum(comments),0))
) AS data
FROM ${viewGroup(range, 'sub_stats')}
WHERE sub_name = $3
GROUP BY time
ORDER BY time ASC`, ...range, sub)
},
revenueGrowthSubs: async (parent, { when, to, from, sub }, { models }) => {
const range = whenRange(when, from, to)
const subExists = await models.sub.findUnique({ where: { name: sub } })
if (!subExists) throw new Error('Sub not found')
return await models.$queryRawUnsafe(`
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time, json_build_array(
json_build_object('name', 'revenue', 'value', coalesce(sum(msats_revenue/1000),0)),
json_build_object('name', 'stacking', 'value', coalesce(sum(msats_stacked/1000),0)),
json_build_object('name', 'spending', 'value', coalesce(sum(msats_spent/1000),0))
) AS data
FROM ${viewGroup(range, 'sub_stats')}
WHERE sub_name = $3
GROUP BY time
ORDER BY time ASC`, ...range, sub)
}
}
}

25
api/resolvers/image.js Normal file
View File

@ -0,0 +1,25 @@
import { USER_ID, AWS_S3_URL_REGEXP } from '@/lib/constants'
import { msatsToSats } from '@/lib/format'
export default {
Query: {
imageFeesInfo: async (parent, { s3Keys }, { models, me }) => {
return imageFeesInfo(s3Keys, { models, me })
}
}
}
export function uploadIdsFromText (text, { models }) {
if (!text) return null
return [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1]))
}
export async function imageFeesInfo (s3Keys, { models, me }) {
// returns info object in this format:
// { bytes24h: int, bytesUnpaid: int, nUnpaid: int, imageFeeMsats: BigInt }
const [info] = await models.$queryRawUnsafe('SELECT * FROM image_fees_info($1::INTEGER, $2::INTEGER[])', me ? me.id : USER_ID.anon, s3Keys)
const imageFee = msatsToSats(info.imageFeeMsats)
const totalFeesMsats = info.nUnpaid * Number(info.imageFeeMsats)
const totalFees = msatsToSats(totalFeesMsats)
return { ...info, imageFee, totalFees, totalFeesMsats }
}

View File

@ -1,8 +1,7 @@
import user from './user'
import message from './message'
import item from './item'
import walletV1 from './wallet'
import walletV2 from '@/wallets/server/resolvers'
import wallet from './wallet'
import lnurl from './lnurl'
import notifications from './notifications'
import invite from './invite'
@ -17,9 +16,9 @@ import { GraphQLJSONObject as JSONObject } from 'graphql-type-json'
import admin from './admin'
import blockHeight from './blockHeight'
import chainFee from './chainFee'
import image from './image'
import { GraphQLScalarType, Kind } from 'graphql'
import { createIntScalar } from 'graphql-scalar'
import paidAction from './paidAction'
const date = new GraphQLScalarType({
name: 'Date',
@ -54,6 +53,5 @@ const limit = createIntScalar({
maximum: 1000
})
export default [user, item, message, walletV1, walletV2, lnurl, notifications, invite, sub,
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee,
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction]
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee, image, { JSONObject }, { Date: date }, { Limit: limit }]

View File

@ -1,15 +1,15 @@
import { inviteSchema, validateSchema } from '@/lib/validate'
import { GraphQLError } from 'graphql'
import { inviteSchema, ssValidate } from '@/lib/validate'
import { msatsToSats } from '@/lib/format'
import assertApiKeyNotPermitted from './apiKey'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { Prisma } from '@prisma/client'
export default {
Query: {
invites: async (parent, args, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
return await models.invite.findMany({
where: {
userId: me.id
@ -29,48 +29,27 @@ export default {
},
Mutation: {
createInvite: async (parent, { id, gift, limit, description }, { me, models }) => {
createInvite: async (parent, { gift, limit }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
assertApiKeyNotPermitted({ me })
await validateSchema(inviteSchema, { id, gift, limit, description })
try {
return await models.invite.create({
data: {
id,
gift,
limit,
userId: me.id,
description
}
})
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002' && error.meta.target.includes('id')) {
throw new GqlInputError('an invite with this code already exists')
}
}
throw error
}
await ssValidate(inviteSchema, { gift, limit })
return await models.invite.create({
data: { gift, limit, userId: me.id }
})
},
revokeInvite: async (parent, { id }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
try {
return await models.invite.update({
where: { id, userId: me.id },
data: { revoked: true }
})
} catch (err) {
if (err.code === 'P2025') {
throw new GqlInputError('invite not found')
}
throw err
}
return await models.invite.update({
where: { id },
data: { revoked: true }
})
}
},
@ -83,10 +62,7 @@ export default {
},
poor: async (invite, args, { me, models }) => {
const user = await models.user.findUnique({ where: { id: invite.userId } })
return msatsToSats(user.msats) < invite.gift && msatsToSats(user.mcredits) < invite.gift
},
description: (invite, args, { me }) => {
return invite.userId === me?.id ? invite.description : undefined
return msatsToSats(user.msats) < invite.gift
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,8 @@
import { randomBytes } from 'crypto'
import { bech32 } from 'bech32'
import { GraphQLError } from 'graphql'
import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey'
import { GqlAuthenticationError } from '@/lib/error'
function encodedUrl (iurl, tag, k1) {
const url = new URL(iurl)
@ -35,7 +35,7 @@ export default {
await assertGofacYourself({ models, headers })
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
assertApiKeyNotPermitted({ me })

View File

@ -1,4 +1,4 @@
import { GqlInputError } from '@/lib/error'
import { GraphQLError } from 'graphql'
export default {
Query: {
@ -11,7 +11,7 @@ export default {
Mutation: {
createMessage: async (parent, { text }, { me, models }) => {
if (!text) {
throw new GqlInputError('must have text')
throw new GraphQLError('Must have text', { extensions: { code: 'BAD_INPUT' } })
}
return await models.message.create({

View File

@ -1,18 +1,17 @@
import { GraphQLError } from 'graphql'
import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor'
import { getItem, filterClause, whereClause, muteClause, activeOrMine } from './item'
import { getItem, filterClause, whereClause, muteClause } from './item'
import { getInvoice, getWithdrawl } from './wallet'
import { pushSubscriptionSchema, validateSchema } from '@/lib/validate'
import { sendPushSubscriptionReply } from '@/lib/webPush'
import { pushSubscriptionSchema, ssValidate } from '@/lib/validate'
import { replyToSubscription } from '@/lib/webPush'
import { getSub } from './sub'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'
export default {
Query: {
notifications: async (parent, { cursor, inc }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
const meFull = await models.user.findUnique({ where: { id: me.id } })
@ -168,8 +167,7 @@ export default {
${whereClause(
'"Item".created_at < $2',
await filterClause(me, models),
muteClause(me),
activeOrMine(me))}
muteClause(me))}
ORDER BY id ASC, CASE
WHEN type = 'Mention' THEN 1
WHEN type = 'Reply' THEN 2
@ -180,6 +178,17 @@ export default {
)`
)
queries.push(
`(SELECT "Item".id::text, "Item"."statusUpdatedAt" AS "sortTime", NULL as "earnedSats",
'JobChanged' AS type
FROM "Item"
WHERE "Item"."userId" = $1
AND "maxBid" IS NOT NULL
AND "statusUpdatedAt" < $2 AND "statusUpdatedAt" <> created_at
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
// territory transfers
queries.push(
`(SELECT "TerritoryTransfer".id::text, "TerritoryTransfer"."created_at" AS "sortTime", NULL as "earnedSats",
@ -218,20 +227,13 @@ export default {
if (meFull.noteDeposits) {
queries.push(
`(SELECT "Invoice".id::text, "Invoice"."confirmedAt" AS "sortTime",
FLOOR("Invoice"."msatsReceived" / 1000) as "earnedSats",
`(SELECT "Invoice".id::text, "Invoice"."confirmedAt" AS "sortTime", FLOOR("msatsReceived" / 1000) as "earnedSats",
'InvoicePaid' AS type
FROM "Invoice"
WHERE "Invoice"."userId" = $1
AND "Invoice"."confirmedAt" IS NOT NULL
AND "Invoice"."created_at" < $2
AND (
("Invoice"."isHeld" IS NULL AND "Invoice"."actionType" IS NULL)
OR (
"Invoice"."actionType" = 'RECEIVE'
AND "Invoice"."actionState" = 'PAID'
)
)
AND "confirmedAt" IS NOT NULL
AND "isHeld" IS NULL
AND created_at < $2
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
@ -239,17 +241,12 @@ export default {
if (meFull.noteWithdrawals) {
queries.push(
`(SELECT "Withdrawl".id::text, MAX(COALESCE("Invoice"."confirmedAt", "Withdrawl".created_at)) AS "sortTime",
FLOOR(MAX("Withdrawl"."msatsPaid" / 1000)) as "earnedSats",
`(SELECT "Withdrawl".id::text, "Withdrawl".created_at AS "sortTime", FLOOR("msatsPaid" / 1000) as "earnedSats",
'WithdrawlPaid' AS type
FROM "Withdrawl"
LEFT JOIN "InvoiceForward" ON "InvoiceForward"."withdrawlId" = "Withdrawl".id
LEFT JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id
WHERE "Withdrawl"."userId" = $1
AND "Withdrawl".status = 'CONFIRMED'
AND "Withdrawl".created_at < $2
AND "InvoiceForward"."id" IS NULL
GROUP BY "Withdrawl".id
AND status = 'CONFIRMED'
AND created_at < $2
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
@ -285,7 +282,6 @@ export default {
FROM "Earn"
WHERE "userId" = $1
AND created_at < $2
AND (type IS NULL OR type NOT IN ('FOREVER_REFERRAL', 'ONE_DAY_REFERRAL'))
GROUP BY "userId", created_at
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
@ -301,51 +297,17 @@ export default {
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
queries.push(
`(SELECT min(id)::text, created_at AS "sortTime", FLOOR(sum(msats) / 1000) as "earnedSats",
'ReferralReward' AS type
FROM "Earn"
WHERE "userId" = $1
AND created_at < $2
AND type IN ('FOREVER_REFERRAL', 'ONE_DAY_REFERRAL')
GROUP BY "userId", created_at
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
}
if (meFull.noteCowboyHat) {
queries.push(
`(SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'CowboyHat' AS type
`(SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'Streak' AS type
FROM "Streak"
WHERE "userId" = $1
AND updated_at < $2
AND type = 'COWBOY_HAT'
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
for (const type of ['HORSE', 'GUN']) {
const gqlType = type.charAt(0) + type.slice(1).toLowerCase()
queries.push(
`(SELECT id::text, "startedAt" AS "sortTime", 0 as "earnedSats", 'New${gqlType}' AS type
FROM "Streak"
WHERE "userId" = $1
AND updated_at < $2
AND type = '${type}'::"StreakType"
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
queries.push(
`(SELECT id::text AS id, "endedAt" AS "sortTime", 0 as "earnedSats", 'Lost${gqlType}' AS type
FROM "Streak"
WHERE "userId" = $1
AND updated_at < $2
AND "endedAt" IS NOT NULL
AND type = '${type}'::"StreakType"
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
}
}
queries.push(
@ -368,37 +330,6 @@ export default {
LIMIT ${LIMIT})`
)
queries.push(
`(SELECT "Invoice".id::text,
CASE
WHEN
"Invoice"."paymentAttempt" < ${WALLET_MAX_RETRIES}
AND "Invoice"."userCancel" = false
AND "Invoice"."cancelledAt" <= now() - interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
THEN "Invoice"."cancelledAt" + interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
ELSE "Invoice"."updated_at"
END AS "sortTime", NULL as "earnedSats", 'Invoicification' AS type
FROM "Invoice"
WHERE "Invoice"."userId" = $1
AND "Invoice"."updated_at" < $2
AND "Invoice"."actionState" = 'FAILED'
AND (
-- this is the inverse of the filter for automated retries
"Invoice"."paymentAttempt" >= ${WALLET_MAX_RETRIES}
OR "Invoice"."userCancel" = true
OR "Invoice"."cancelledAt" <= now() - interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
)
AND (
"Invoice"."actionType" = 'ITEM_CREATE' OR
"Invoice"."actionType" = 'ZAP' OR
"Invoice"."actionType" = 'DOWN_ZAP' OR
"Invoice"."actionType" = 'POLL_VOTE' OR
"Invoice"."actionType" = 'BOOST'
)
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
const notifications = await models.$queryRawUnsafe(
`SELECT id, "sortTime", "earnedSats", type,
"sortTime" AS "minSortTime"
@ -421,10 +352,10 @@ export default {
Mutation: {
savePushSubscription: async (parent, { endpoint, p256dh, auth, oldEndpoint }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
await validateSchema(pushSubscriptionSchema, { endpoint, p256dh, auth })
await ssValidate(pushSubscriptionSchema, { endpoint, p256dh, auth })
let dbPushSubscription
if (oldEndpoint) {
@ -439,18 +370,18 @@ export default {
console.log(`[webPush] created subscription for user ${me.id}: endpoint=${endpoint}`)
}
await sendPushSubscriptionReply(dbPushSubscription)
await replyToSubscription(dbPushSubscription.id, { title: 'Stacker News notifications are now active' })
return dbPushSubscription
},
deletePushSubscription: async (parent, { endpoint }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
const subscription = await models.pushSubscription.findFirst({ where: { endpoint, userId: Number(me.id) } })
if (!subscription) {
throw new GqlInputError('endpoint not found')
throw new GraphQLError('endpoint not found', { extensions: { code: 'BAD_INPUT' } })
}
const deletedSubscription = await models.pushSubscription.delete({ where: { id: subscription.id } })
console.log(`[webPush] deleted subscription ${deletedSubscription.id} of user ${deletedSubscription.userId} due to client request`)
@ -484,13 +415,8 @@ export default {
},
TerritoryTransfer: {
sub: async (n, args, { models, me }) => {
const [sub] = await models.$queryRaw`
SELECT "Sub".*
FROM "TerritoryTransfer"
JOIN "Sub" ON "Sub"."name" = "TerritoryTransfer"."subName"
WHERE "TerritoryTransfer"."id" = ${Number(n.id)}`
return sub
const transfer = await models.territoryTransfer.findUnique({ where: { id: Number(n.id) }, include: { sub: true } })
return transfer.sub
}
},
JobChanged: {
@ -510,31 +436,14 @@ export default {
return subAct.subName
}
},
ReferralSource: {
__resolveType: async (n, args, { models }) => n.type
},
Referral: {
source: async (n, args, { models, me }) => {
// retrieve the referee landing record
const referral = await models.oneDayReferral.findFirst({ where: { refereeId: Number(n.id), landing: true } })
if (!referral) return null // if no landing record, it will return a generic referral
switch (referral.type) {
case 'POST':
case 'COMMENT': return { ...await getItem(n, { id: referral.typeId }, { models, me }), type: 'Item' }
case 'TERRITORY': return { ...await getSub(n, { name: referral.typeId }, { models, me }), type: 'Sub' }
case 'PROFILE': return { ...await models.user.findUnique({ where: { id: Number(referral.typeId) }, select: { name: true } }), type: 'User' }
default: return null
}
}
},
CowboyHat: {
Streak: {
days: async (n, args, { models }) => {
const res = await models.$queryRaw`
SELECT "endedAt"::date - "startedAt"::date AS days
SELECT "endedAt" - "startedAt" AS days
FROM "Streak"
WHERE id = ${Number(n.id)} AND "endedAt" IS NOT NULL
`
return res.length ? res[0].days : null
}
},
@ -560,22 +469,6 @@ export default {
return null
}
},
ReferralReward: {
sources: async (n, args, { me, models }) => {
const [sources] = await models.$queryRawUnsafe(`
SELECT
COALESCE(FLOOR(sum(msats) FILTER(WHERE type = 'FOREVER_REFERRAL') / 1000), 0) AS forever,
COALESCE(FLOOR(sum(msats) FILTER(WHERE type = 'ONE_DAY_REFERRAL') / 1000), 0) AS "oneDay"
FROM "Earn"
WHERE "userId" = $1 AND created_at = $2
`, Number(me.id), new Date(n.sortTime))
if (sources.forever + sources.oneDay > 0) {
return sources
}
return null
}
},
Mention: {
mention: async (n, args, { models }) => true,
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
@ -586,9 +479,6 @@ export default {
InvoicePaid: {
invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models })
},
Invoicification: {
invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models })
},
WithdrawlPaid: {
withdrawl: async (n, args, { me, models }) => getWithdrawl(n, { id: n.id }, { me, models })
},

View File

@ -1,11 +1,13 @@
import { GqlAuthorizationError } from '@/lib/error'
import { GraphQLError } from 'graphql'
// this function makes america more secure apparently
export default async function assertGofacYourself ({ models, headers, ip }) {
const country = await gOFACYourself({ models, headers, ip })
if (!country) return
throw new GqlAuthorizationError(`Your IP address is in ${country}. We cannot provide financial services to residents of ${country}.`)
throw new GraphQLError(
`Your IP address is in ${country}. We cannot provide financial services to residents of ${country}.`,
{ extensions: { code: 'FORBIDDEN' } })
}
export async function gOFACYourself ({ models, headers = {}, ip }) {

View File

@ -1,89 +0,0 @@
import { retryPaidAction } from '../paidAction'
import { USER_ID, WALLET_MAX_RETRIES, WALLET_RETRY_TIMEOUT_MS } from '@/lib/constants'
function paidActionType (actionType) {
switch (actionType) {
case 'ITEM_CREATE':
case 'ITEM_UPDATE':
return 'ItemPaidAction'
case 'ZAP':
case 'DOWN_ZAP':
case 'BOOST':
return 'ItemActPaidAction'
case 'TERRITORY_CREATE':
case 'TERRITORY_UPDATE':
case 'TERRITORY_BILLING':
case 'TERRITORY_UNARCHIVE':
return 'SubPaidAction'
case 'DONATE':
return 'DonatePaidAction'
case 'POLL_VOTE':
return 'PollVotePaidAction'
case 'RECEIVE':
return 'ReceivePaidAction'
case 'BUY_CREDITS':
return 'BuyCreditsPaidAction'
default:
throw new Error('Unknown action type')
}
}
export default {
Query: {
paidAction: async (parent, { invoiceId }, { models, me }) => {
const invoice = await models.invoice.findUnique({
where: {
id: invoiceId,
userId: me?.id ?? USER_ID.anon
}
})
if (!invoice) {
throw new Error('Invoice not found')
}
return {
type: paidActionType(invoice.actionType),
invoice,
result: invoice.actionResult,
paymentMethod: invoice.actionOptimistic ? 'OPTIMISTIC' : 'PESSIMISTIC'
}
}
},
Mutation: {
retryPaidAction: async (parent, { invoiceId, newAttempt }, { models, me, lnd }) => {
if (!me) {
throw new Error('You must be logged in')
}
// make sure only one client at a time can retry by acquiring a lock that expires
const [invoice] = await models.$queryRaw`
UPDATE "Invoice"
SET "retryPendingSince" = now()
WHERE
id = ${invoiceId} AND
"userId" = ${me.id} AND
"actionState" = 'FAILED' AND
("retryPendingSince" IS NULL OR "retryPendingSince" < now() - ${`${WALLET_RETRY_TIMEOUT_MS} milliseconds`}::interval)
RETURNING *`
if (!invoice) {
throw new Error('Invoice not found or retry pending')
}
// do we want to retry a payment from the beginning with all sender and receiver wallets?
const paymentAttempt = newAttempt ? invoice.paymentAttempt + 1 : invoice.paymentAttempt
if (paymentAttempt > WALLET_MAX_RETRIES) {
throw new Error('Payment has been retried too many times')
}
const result = await retryPaidAction(invoice.actionType, { invoice }, { paymentAttempt, models, me, lnd })
return {
...result,
type: paidActionType(invoice.actionType)
}
}
},
PaidAction: {
__resolveType: obj => obj.type
}
}

View File

@ -1,27 +1,36 @@
import { SUPPORTED_CURRENCIES } from '@/lib/currency'
import { cachedFetcher } from '@/lib/fetch'
const cache = new Map()
const expiresIn = 30000 // in milliseconds
const getPrice = cachedFetcher(async function fetchPrice (fiat = 'USD') {
async function fetchPrice (fiat) {
const url = `https://api.coinbase.com/v2/prices/BTC-${fiat}/spot`
try {
const res = await fetch(url)
const body = await res.json()
return parseFloat(body.data.amount)
} catch (err) {
console.error(err)
return -1
const price = await fetch(url)
.then((res) => res.json())
.then((body) => parseFloat(body.data.amount))
.catch((err) => {
console.error(err)
return -1
})
cache.set(fiat, { price, createdAt: Date.now() })
return price
}
async function getPrice (fiat) {
fiat ??= 'USD'
if (cache.has(fiat)) {
const { price, createdAt } = cache.get(fiat)
const expired = createdAt + expiresIn < Date.now()
if (expired) fetchPrice(fiat).catch(console.error) // update cache
return price // serve stale price (this on the SSR critical path)
} else {
fetchPrice(fiat).catch(console.error)
}
}, {
maxSize: SUPPORTED_CURRENCIES.length,
cacheExpiry: 60 * 1000, // 1 minute
forceRefreshThreshold: 0, // never force refresh
keyGenerator: (fiat = 'USD') => fiat
})
return null
}
export default {
Query: {
price: async (parent, { fiatCurrency }, ctx) => {
return await getPrice(fiatCurrency) || -1
return await getPrice(fiatCurrency)
}
}
}

View File

@ -1,28 +1,56 @@
import { GraphQLError } from 'graphql'
import { withClause, intervalClause } from './growth'
import { timeUnitForRange, whenRange } from '@/lib/time'
import { viewGroup } from './growth'
import { GqlAuthenticationError } from '@/lib/error'
export default {
Query: {
referrals: async (parent, { when, from, to }, { models, me }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
const range = whenRange(when, from, to)
return await models.$queryRawUnsafe(`
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time,
json_build_array(
json_build_object('name', 'referrals', 'value', COALESCE(SUM(referrals), 0)),
json_build_object('name', 'one day referrals', 'value', COALESCE(SUM(one_day_referrals), 0)),
json_build_object('name', 'referral sats', 'value', FLOOR(COALESCE(SUM(msats_referrals), 0) / 1000.0)),
json_build_object('name', 'one day referral sats', 'value', FLOOR(COALESCE(SUM(msats_one_day_referrals), 0) / 1000.0))
const [{ totalSats }] = await models.$queryRawUnsafe(`
SELECT COALESCE(FLOOR(sum(msats) / 1000), 0) as "totalSats"
FROM "ReferralAct"
WHERE ${intervalClause(range, 'ReferralAct')}
AND "ReferralAct"."referrerId" = $3
`, ...range, Number(me.id))
const [{ totalReferrals }] = await models.$queryRawUnsafe(`
SELECT count(*)::INTEGER as "totalReferrals"
FROM users
WHERE ${intervalClause(range, 'users')}
AND "referrerId" = $3
`, ...range, Number(me.id))
const stats = await models.$queryRawUnsafe(
`${withClause(range)}
SELECT time, json_build_array(
json_build_object('name', 'referrals', 'value', count(*) FILTER (WHERE act = 'REFERREE')),
json_build_object('name', 'sats', 'value', FLOOR(COALESCE(sum(msats) FILTER (WHERE act IN ('BOOST', 'STREAM', 'FEE')), 0)))
) AS data
FROM ${viewGroup(range, 'user_stats')}
WHERE id = ${me.id}
GROUP BY time
ORDER BY time ASC`, ...range)
FROM times
LEFT JOIN
((SELECT "ReferralAct".created_at, "ReferralAct".msats / 1000.0 as msats, "ItemAct".act::text as act
FROM "ReferralAct"
JOIN "ItemAct" ON "ItemAct".id = "ReferralAct"."itemActId"
WHERE ${intervalClause(range, 'ReferralAct')}
AND "ReferralAct"."referrerId" = $3)
UNION ALL
(SELECT created_at, 0.0 as sats, 'REFERREE' as act
FROM users
WHERE ${intervalClause(range, 'users')}
AND "referrerId" = $3)) u ON time = date_trunc('${timeUnitForRange(range)}', u.created_at)
GROUP BY time
ORDER BY time ASC`, ...range, Number(me.id))
return {
totalSats,
totalReferrals,
stats
}
}
}
}

View File

@ -1,7 +1,9 @@
import { amountSchema, validateSchema } from '@/lib/validate'
import { getAd, getItem } from './item'
import performPaidAction from '../paidAction'
import { GqlInputError } from '@/lib/error'
import { GraphQLError } from 'graphql'
import { amountSchema, ssValidate } from '@/lib/validate'
import serialize from './serial'
import { USER_ID } from '@/lib/constants'
import { getItem } from './item'
import { topUsers } from './user'
let rewardCache
@ -62,21 +64,21 @@ async function getMonthlyRewards (when, models) {
async function getRewards (when, models) {
if (when) {
if (when.length > 1) {
throw new GqlInputError('too many dates')
throw new GraphQLError('too many dates', { extensions: { code: 'BAD_USER_INPUT' } })
}
when.forEach(w => {
if (isNaN(new Date(w))) {
throw new GqlInputError('invalid date')
throw new GraphQLError('invalid date', { extensions: { code: 'BAD_USER_INPUT' } })
}
})
if (new Date(when[0]) > new Date(when[when.length - 1])) {
throw new GqlInputError('bad date range')
throw new GraphQLError('bad date range', { extensions: { code: 'BAD_USER_INPUT' } })
}
if (new Date(when[0]).getTime() > new Date('2024-03-01').getTime() && new Date(when[0]).getTime() < new Date('2024-05-02').getTime()) {
// after 3/1/2024 and until 5/1/2024, we reward monthly on the 1st
if (new Date(when[0]).getUTCDate() !== 1) {
throw new GqlInputError('bad reward date')
throw new GraphQLError('invalid reward date', { extensions: { code: 'BAD_USER_INPUT' } })
}
return await getMonthlyRewards(when, models)
@ -118,11 +120,11 @@ export default {
}
if (!when || when.length > 2) {
throw new GqlInputError('bad date range')
throw new GraphQLError('invalid date range', { extensions: { code: 'BAD_USER_INPUT' } })
}
for (const w of when) {
if (isNaN(new Date(w))) {
throw new GqlInputError('invalid date')
throw new GraphQLError('invalid date', { extensions: { code: 'BAD_USER_INPUT' } })
}
}
@ -140,7 +142,6 @@ export default {
(SELECT FLOOR("Earn".msats / 1000.0) as sats, type, rank, "typeId"
FROM "Earn"
WHERE "Earn"."userId" = ${me.id}
AND (type IS NULL OR type NOT IN ('FOREVER_REFERRAL', 'ONE_DAY_REFERRAL'))
AND date_trunc('day', "Earn".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = days_cte.day
ORDER BY "Earn".msats DESC)
) "Earn"
@ -151,21 +152,24 @@ export default {
}
},
Rewards: {
total: async (parent, args, { models }) => {
if (!parent.total) {
return 0
}
return parent.total
},
ad: async (parent, args, { me, models }) => {
return await getAd(parent, { }, { me, models })
leaderboard: async (parent, args, { models, ...context }) => {
// get to and from using postgres because it's easier to do there
const [{ to, from }] = await models.$queryRaw`
SELECT date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago' as from,
(date_trunc('day', (now() AT TIME ZONE 'America/Chicago')) AT TIME ZONE 'America/Chicago') + interval '1 day - 1 second' as to`
return await topUsers(parent, { when: 'custom', to: new Date(to).getTime().toString(), from: new Date(from).getTime().toString(), limit: 100 }, { models, ...context })
}
},
Mutation: {
donateToRewards: async (parent, { sats }, { me, models, lnd }) => {
await validateSchema(amountSchema, { amount: sats })
donateToRewards: async (parent, { sats, hash, hmac }, { me, models, lnd }) => {
await ssValidate(amountSchema, { amount: sats })
return await performPaidAction('DONATE', { sats }, { me, models, lnd })
await serialize(
models.$queryRaw`SELECT donate(${sats}::INTEGER, ${me?.id || USER_ID.anon}::INTEGER)`,
{ models, lnd, me, hash, hmac, fee: sats, verifyPayment: !!hash || !me }
)
return sats
}
},
Reward: {

View File

@ -1,7 +1,6 @@
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { whenToFrom } from '@/lib/time'
import { getItem, itemQueryWithMeta, SELECT } from './item'
import { parse } from 'tldts'
function queryParts (q) {
const regex = /"([^"]*)"/gm
@ -175,6 +174,7 @@ export default {
search: async (parent, { q, cursor, sort, what, when, from: whenFrom, to: whenTo }, { me, models, search }) => {
const decodedCursor = decodeCursor(cursor)
let sitems = null
let termQueries = []
// short circuit: return empty result if either:
// 1. no query provided, or
@ -186,237 +186,143 @@ export default {
}
}
// build query in parts:
// filters: determine the universe of potential search candidates
// termQueries: queries related to the actual search terms
// functions: rank modifiers to boost by recency or popularity
const filters = []
const termQueries = []
const functions = []
// filters for item types
const whatArr = []
switch (what) {
case 'posts': // posts only
filters.push({ bool: { must_not: { exists: { field: 'parentId' } } } })
case 'posts':
whatArr.push({ bool: { must_not: { exists: { field: 'parentId' } } } })
break
case 'comments': // comments only
filters.push({ bool: { must: { exists: { field: 'parentId' } } } })
case 'comments':
whatArr.push({ bool: { must: { exists: { field: 'parentId' } } } })
break
case 'bookmarks':
if (me?.id) {
filters.push({ match: { bookmarkedBy: me?.id } })
whatArr.push({ match: { bookmarkedBy: me?.id } })
}
break
default:
break
}
// filter for active posts
filters.push(
me
? {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } },
{ match: { userId: me.id } }
]
}
}
: {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } }
]
}
}
)
// filter for time range
const whenRange = when === 'custom'
? {
gte: whenFrom,
lte: new Date(Math.min(new Date(Number(whenTo)), decodedCursor.time))
}
: {
lte: decodedCursor.time,
gte: whenToFrom(when)
}
filters.push({ range: { createdAt: whenRange } })
// filter for non negative wvotes
filters.push({ range: { wvotes: { gte: 0 } } })
// decompose the search terms
const { query: _query, quotes, nym, url, territory } = queryParts(q)
const query = _query
let query = _query
const isUrlSearch = url && query.length === 0 // exclusively searching for an url
// if search contains a url term, modify the query text
if (url) {
let uri = url.slice(4)
termQueries.push({
match_bool_prefix: { url: { query: uri, operator: 'and', boost: 1000 } }
})
const parsed = parse(uri)
if (parsed?.subdomain?.length > 0) {
uri = uri.replace(`${parsed.subdomain}.`, '')
}
termQueries.push({
wildcard: { url: { value: `*${uri}*` } }
})
const isFQDN = url.startsWith('url:www.')
const domain = isFQDN ? url.slice(8) : url.slice(4)
const fqdn = `www.${domain}`
query = (isUrlSearch) ? `${domain} ${fqdn}` : `${query.trim()} ${domain}`
}
// if nym, items must contain nym
if (nym) {
filters.push({ wildcard: { 'user.name': `*${nym.slice(1).toLowerCase()}*` } })
// push same requirement to termQueries to avoid empty should clause
termQueries.push({ wildcard: { 'user.name': `*${nym.slice(1).toLowerCase()}*` } })
whatArr.push({ wildcard: { 'user.name': `*${nym.slice(1).toLowerCase()}*` } })
}
// if territory, item must be from territory
if (territory) {
filters.push({ match: { 'sub.name': territory.slice(1) } })
// push same requirement to termQueries to avoid empty should clause
termQueries.push({ match: { 'sub.name': territory.slice(1) } })
whatArr.push({ match: { 'sub.name': territory.slice(1) } })
}
// if quoted phrases, items must contain entire phrase
termQueries.push({
// all terms are matched in fields
multi_match: {
query,
type: 'best_fields',
fields: ['title^100', 'text'],
minimum_should_match: (isUrlSearch) ? 1 : '100%',
boost: 1000
}
})
for (const quote of quotes) {
filters.push({
whatArr.push({
multi_match: {
query: quote,
fields: ['title.exact', 'text.exact'],
type: 'phrase'
type: 'phrase',
fields: ['title', 'text']
}
})
}
// if we search for an exact string only, everything must match
// so score purely on sort field
let boostMode = query ? 'multiply' : 'replace'
let sortField
let sortMod = 'log1p'
switch (sort) {
case 'comments':
sortField = 'ncomments'
sortMod = 'square'
break
case 'sats':
sortField = 'sats'
break
case 'recent':
sortField = 'createdAt'
sortMod = 'square'
boostMode = 'replace'
break
default:
sortField = 'wvotes'
sortMod = 'none'
break
}
const functions = [
{
field_value_factor: {
field: sortField,
modifier: sortMod,
factor: 1.2
}
}
]
if (sort === 'recent' && !isUrlSearch) {
// prioritize exact matches
termQueries.push({
multi_match: {
query: quote,
fields: ['title.exact^10', 'text.exact'],
query,
type: 'phrase',
fields: ['title^100', 'text'],
boost: 1000
}
})
}
switch (sort) {
case 'comments':
functions.push({
field_value_factor: {
field: 'ncomments',
modifier: 'log1p'
}
})
break
case 'sats':
functions.push({
field_value_factor: {
field: 'sats',
modifier: 'log1p'
}
})
break
case 'recent':
functions.push({
gauss: {
createdAt: {
origin: 'now',
scale: '7d',
decay: 0.5
}
}
})
break
case 'zaprank':
functions.push({
field_value_factor: {
field: 'wvotes',
modifier: 'log1p'
}
})
break
default:
break
}
let osQuery = {
function_score: {
query: {
bool: {
filter: filters,
should: termQueries,
minimum_should_match: termQueries.length > 0 ? 1 : 0
}
},
functions,
score_mode: 'multiply',
boost_mode: 'multiply'
}
}
// query for search terms
if (query.length) {
// keyword based subquery, to be used on its own or in conjunction with a neural
// search
const subquery = [
{
multi_match: {
query,
type: 'best_fields',
fields: ['title^10', 'text'],
fuzziness: 'AUTO',
minimum_should_match: 1
}
},
// all match matches higher
{
multi_match: {
query,
type: 'best_fields',
fields: ['title^10', 'text'],
minimum_should_match: '100%',
boost: 100
}
},
// phrase match matches higher
{
multi_match: {
query,
type: 'phrase',
fields: ['title^10', 'text'],
boost: 1000
}
},
// match on exact fields higher
{
multi_match: {
query,
type: 'best_fields',
fields: ['title.exact^10', 'text.exact'],
boost: 100
}
},
// exact phrase matches higher
{
multi_match: {
query,
fields: ['title.exact^10', 'text.exact'],
type: 'phrase',
boost: 10000
}
} else {
// allow fuzzy matching with partial matches
termQueries.push({
multi_match: {
query,
type: 'most_fields',
fields: ['title^100', 'text'],
fuzziness: 'AUTO',
prefix_length: 3,
minimum_should_match: (isUrlSearch) ? 1 : '60%'
}
]
})
functions.push({
// small bias toward posts with comments
field_value_factor: {
field: 'ncomments',
modifier: 'ln1p',
factor: 1
}
},
{
// small bias toward recent posts
field_value_factor: {
field: 'createdAt',
modifier: 'log1p',
factor: 1
}
})
}
osQuery.function_score.query.bool.should = [...termQueries, ...subquery]
osQuery.function_score.query.bool.minimum_should_match = 1
// use hybrid neural search if model id is available, otherwise use only
// keyword search
if (process.env.OPENSEARCH_MODEL_ID) {
osQuery = {
if (query.length) {
// if we have a model id and we aren't sort by recent, use neural search
if (process.env.OPENSEARCH_MODEL_ID && sort !== 'recent') {
termQueries = {
hybrid: {
pagination_depth: 50,
queries: [
{
bool: {
@ -439,18 +345,32 @@ export default {
}
}
}
],
filter: filters,
minimum_should_match: 1
]
}
},
osQuery
{
bool: {
should: termQueries
}
}
]
}
}
}
} else {
termQueries = []
}
const whenRange = when === 'custom'
? {
gte: whenFrom,
lte: new Date(Math.min(new Date(Number(whenTo)), decodedCursor.time))
}
: {
lte: decodedCursor.time,
gte: whenToFrom(when)
}
try {
sitems = await search.search({
index: process.env.OPENSEARCH_INDEX,
@ -464,13 +384,49 @@ export default {
},
from: decodedCursor.offset,
body: {
query: osQuery,
query: {
function_score: {
query: {
bool: {
must: termQueries,
filter: [
...whatArr,
me
? {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } },
{ match: { userId: me.id } }
]
}
}
: {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } }
]
}
},
{
range:
{
createdAt: whenRange
}
},
{ range: { wvotes: { gte: 0 } } }
]
}
},
functions,
boost_mode: boostMode
}
},
highlight: {
fields: {
title: { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] },
'title.exact': { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] },
text: { number_of_fragments: 5, order: 'score', pre_tags: ['***'], post_tags: ['***'] },
'text.exact': { number_of_fragments: 5, order: 'score', pre_tags: ['***'], post_tags: ['***'] }
text: { number_of_fragments: 5, order: 'score', pre_tags: ['***'], post_tags: ['***'] }
}
}
}
@ -502,17 +458,11 @@ export default {
${SELECT}, rank
FROM "Item"
JOIN r ON "Item".id = r.id`,
orderBy: 'ORDER BY rank ASC, msats DESC'
orderBy: 'ORDER BY rank ASC'
})).map((item, i) => {
const e = sitems.body.hits.hits[i]
// prefer the fuzzier highlight for title
item.searchTitle = e.highlight?.title?.[0] || e.highlight?.['title.exact']?.[0] || item.title
// prefer the exact highlight for text
const searchTextHighlight = e.highlight?.['text.exact'] || e.highlight?.text || []
item.searchText = searchTextHighlight?.join(' ... ')
item.searchTitle = (e.highlight?.title && e.highlight.title[0]) || item.title
item.searchText = (e.highlight?.text && e.highlight.text.join(' ... ')) || undefined
return item
})

141
api/resolvers/serial.js Normal file
View File

@ -0,0 +1,141 @@
import { GraphQLError } from 'graphql'
import { timingSafeEqual } from 'crypto'
import retry from 'async-retry'
import Prisma from '@prisma/client'
import { settleHodlInvoice } from 'ln-service'
import { createHmac } from './wallet'
import { msatsToSats, numWithUnits } from '@/lib/format'
import { BALANCE_LIMIT_MSATS } from '@/lib/constants'
export default async function serialize (trx, { models, lnd, me, hash, hmac, fee, verifyPayment: verify }) {
// wrap first argument in array if not array already
const isArray = Array.isArray(trx)
if (!isArray) trx = [trx]
// conditional queries can be added inline using && syntax
// we filter any falsy value out here
trx = trx.filter(q => !!q)
let invoice
if (verify) {
invoice = await verifyPayment(models, hash, hmac, fee)
trx = [
models.$executeRaw`SELECT confirm_invoice(${hash}, ${invoice.msatsReceived})`,
...trx
]
}
let results = await retry(async bail => {
try {
const [, ...results] = await models.$transaction(
[models.$executeRaw`SELECT ASSERT_SERIALIZED()`, ...trx],
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable })
return results
} catch (error) {
console.log(error)
// two cases where we get insufficient funds:
// 1. plpgsql function raises
// 2. constraint violation via a prisma call
// XXX prisma does not provide a way to distinguish these cases so we
// have to check the error message
if (error.message.includes('SN_INSUFFICIENT_FUNDS') ||
error.message.includes('\\"users\\" violates check constraint \\"msats_positive\\"')) {
bail(new GraphQLError('insufficient funds', { extensions: { code: 'BAD_INPUT' } }))
}
if (error.message.includes('SN_NOT_SERIALIZABLE')) {
bail(new Error('wallet balance transaction is not serializable'))
}
if (error.message.includes('SN_CONFIRMED_WITHDRAWL_EXISTS')) {
bail(new Error('withdrawal invoice already confirmed (to withdraw again create a new invoice)'))
}
if (error.message.includes('SN_PENDING_WITHDRAWL_EXISTS')) {
bail(new Error('withdrawal invoice exists and is pending'))
}
if (error.message.includes('SN_INELIGIBLE')) {
bail(new Error('user ineligible for gift'))
}
if (error.message.includes('SN_UNSUPPORTED')) {
bail(new Error('unsupported action'))
}
if (error.message.includes('SN_DUPLICATE')) {
bail(new Error('duplicate not allowed'))
}
if (error.message.includes('SN_REVOKED_OR_EXHAUSTED')) {
bail(new Error('faucet has been revoked or is exhausted'))
}
if (error.message.includes('SN_INV_PENDING_LIMIT')) {
bail(new Error('too many pending invoices'))
}
if (error.message.includes('SN_INV_EXCEED_BALANCE')) {
bail(new Error(`pending invoices and withdrawals must not cause balance to exceed ${numWithUnits(msatsToSats(BALANCE_LIMIT_MSATS))}`))
}
if (error.message.includes('40001') || error.code === 'P2034') {
throw new Error('wallet balance serialization failure - try again')
}
if (error.message.includes('23514') || ['P2002', 'P2003', 'P2004'].includes(error.code)) {
bail(new Error('constraint failure'))
}
bail(error)
}
}, {
minTimeout: 10,
maxTimeout: 100,
retries: 10
})
if (hash) {
if (invoice?.isHeld) {
await settleHodlInvoice({ secret: invoice.preimage, lnd })
}
// remove first element since that is the confirmed invoice
results = results.slice(1)
}
// if first argument was not an array, unwrap the result
return isArray ? results : results[0]
}
async function verifyPayment (models, hash, hmac, fee) {
if (!hash) {
throw new GraphQLError('hash required', { extensions: { code: 'BAD_INPUT' } })
}
if (!hmac) {
throw new GraphQLError('hmac required', { extensions: { code: 'BAD_INPUT' } })
}
const hmac2 = createHmac(hash)
if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) {
throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } })
}
const invoice = await models.invoice.findUnique({
where: { hash },
include: {
user: true
}
})
if (!invoice) {
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
}
const expired = new Date(invoice.expiresAt) <= new Date()
if (expired) {
throw new GraphQLError('invoice expired', { extensions: { code: 'BAD_INPUT' } })
}
if (invoice.confirmedAt) {
throw new GraphQLError('invoice already used', { extensions: { code: 'BAD_INPUT' } })
}
if (invoice.cancelled) {
throw new GraphQLError('invoice was canceled', { extensions: { code: 'BAD_INPUT' } })
}
if (!invoice.msatsReceived) {
throw new GraphQLError('invoice was not paid', { extensions: { code: 'BAD_INPUT' } })
}
if (fee && msatsToSats(invoice.msatsReceived) < fee) {
throw new GraphQLError('invoice amount too low', { extensions: { code: 'BAD_INPUT' } })
}
return invoice
}

View File

@ -1,11 +1,66 @@
import { whenRange } from '@/lib/time'
import { validateSchema, territorySchema } from '@/lib/validate'
import { GraphQLError } from 'graphql'
import serialize from './serial'
import { TERRITORY_COST_MONTHLY, TERRITORY_COST_ONCE, TERRITORY_COST_YEARLY, TERRITORY_PERIOD_COST } from '@/lib/constants'
import { datePivot, whenRange } from '@/lib/time'
import { ssValidate, territorySchema } from '@/lib/validate'
import { nextBilling, proratedBillingCost } from '@/lib/territory'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { viewGroup } from './growth'
import { subViewGroup } from './growth'
import { notifyTerritoryTransfer } from '@/lib/webPush'
import performPaidAction from '../paidAction'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { uploadIdsFromText } from './upload'
export function paySubQueries (sub, models) {
if (sub.billingType === 'ONCE') {
return []
}
// if in active or grace, consider we are billing them from where they are paid up
// and use grandfathered cost
let billedLastAt = sub.billPaidUntil
let billingCost = sub.billingCost
// if the sub is archived, they are paying to reactivate it
if (sub.status === 'STOPPED') {
// get non-grandfathered cost and reset their billing to start now
billedLastAt = new Date()
billingCost = TERRITORY_PERIOD_COST(sub.billingType)
}
const billPaidUntil = nextBilling(billedLastAt, sub.billingType)
const cost = BigInt(billingCost) * BigInt(1000)
return [
models.user.update({
where: {
id: sub.userId
},
data: {
msats: {
decrement: cost
}
}
}),
// update 'em
models.sub.update({
where: {
name: sub.name
},
data: {
billedLastAt,
billPaidUntil,
billingCost,
status: 'ACTIVE'
}
}),
// record 'em
models.subAct.create({
data: {
userId: sub.userId,
subName: sub.name,
msats: cost,
type: 'BILLING'
}
})
]
}
export async function getSub (parent, { name }, { models, me }) {
if (!name) return null
@ -36,27 +91,6 @@ export async function getSub (parent, { name }, { models, me }) {
export default {
Query: {
sub: getSub,
subSuggestions: async (parent, { q, limit = 5 }, { models }) => {
let subs = []
if (q) {
subs = await models.$queryRaw`
SELECT name
FROM "Sub"
WHERE status = 'ACTIVE'
AND SIMILARITY(name, ${q}) > 0.1
ORDER BY SIMILARITY(name, ${q}) DESC
LIMIT ${limit}`
} else {
subs = await models.$queryRaw`
SELECT name
FROM "Sub"
WHERE status = 'ACTIVE'
ORDER BY name ASC
LIMIT ${limit}`
}
return subs
},
subs: async (parent, args, { models, me }) => {
if (me) {
const currentUser = await models.user.findUnique({ where: { id: me.id } })
@ -116,8 +150,8 @@ export default {
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
COALESCE(sum(posts), 0) as nposts,
COALESCE(sum(comments), 0) as ncomments
FROM ${viewGroup(range, 'sub_stats')}
JOIN "Sub" on "Sub".name = u.sub_name
FROM ${subViewGroup(range)} ss
JOIN "Sub" on "Sub".name = ss.sub_name
GROUP BY "Sub".name
ORDER BY ${column} DESC NULLS LAST, "Sub".created_at ASC
OFFSET $3
@ -128,14 +162,14 @@ export default {
subs
}
},
userSubs: async (_parent, { name, cursor, when, by, from, to, limit = LIMIT }, { models, me }) => {
userSubs: async (_parent, { name, cursor, when, by, from, to, limit = LIMIT }, { models }) => {
if (!name) {
throw new GqlInputError('must supply user name')
throw new GraphQLError('must supply user name', { extensions: { code: 'BAD_INPUT' } })
}
const user = await models.user.findUnique({ where: { name } })
if (!user) {
throw new GqlInputError('no user has that name')
throw new GraphQLError('no user has that name', { extensions: { code: 'BAD_INPUT' } })
}
const decodedCursor = decodeCursor(cursor)
@ -151,75 +185,43 @@ export default {
}
const subs = await models.$queryRawUnsafe(`
SELECT "Sub".*,
"Sub".created_at as "createdAt",
COALESCE(floor(sum(msats_revenue)/1000), 0) as revenue,
COALESCE(floor(sum(msats_stacked)/1000), 0) as stacked,
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
COALESCE(sum(posts), 0) as nposts,
COALESCE(sum(comments), 0) as ncomments,
ss."userId" IS NOT NULL as "meSubscription",
ms."userId" IS NOT NULL as "meMuteSub"
FROM ${viewGroup(range, 'sub_stats')}
JOIN "Sub" on "Sub".name = u.sub_name
LEFT JOIN "SubSubscription" ss ON ss."subName" = "Sub".name AND ss."userId" IS NOT DISTINCT FROM $4
LEFT JOIN "MuteSub" ms ON ms."subName" = "Sub".name AND ms."userId" IS NOT DISTINCT FROM $4
WHERE "Sub"."userId" = $3
AND "Sub".status = 'ACTIVE'
GROUP BY "Sub".name, ss."userId", ms."userId"
ORDER BY ${column} DESC NULLS LAST, "Sub".created_at ASC
OFFSET $5
LIMIT $6
`, ...range, user.id, me?.id, decodedCursor.offset, limit)
SELECT "Sub".*,
"Sub".created_at as "createdAt",
COALESCE(floor(sum(msats_revenue)/1000), 0) as revenue,
COALESCE(floor(sum(msats_stacked)/1000), 0) as stacked,
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
COALESCE(sum(posts), 0) as nposts,
COALESCE(sum(comments), 0) as ncomments
FROM ${subViewGroup(range)} ss
JOIN "Sub" on "Sub".name = ss.sub_name
WHERE "Sub"."userId" = $3
AND "Sub".status = 'ACTIVE'
GROUP BY "Sub".name
ORDER BY ${column} DESC NULLS LAST, "Sub".created_at ASC
OFFSET $4
LIMIT $5`, ...range, user.id, decodedCursor.offset, limit)
return {
cursor: subs.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
subs
}
},
mySubscribedSubs: async (parent, { cursor }, { models, me }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const decodedCursor = decodeCursor(cursor)
const subs = await models.$queryRaw`
SELECT "Sub".*,
"MuteSub"."userId" IS NOT NULL as "meMuteSub",
TRUE as "meSubscription"
FROM "SubSubscription"
JOIN "Sub" ON "SubSubscription"."subName" = "Sub".name
LEFT JOIN "MuteSub" ON "MuteSub"."subName" = "Sub".name AND "MuteSub"."userId" = ${me.id}
WHERE "SubSubscription"."userId" = ${me.id}
AND "Sub".status <> 'STOPPED'
ORDER BY "Sub".name ASC
OFFSET ${decodedCursor.offset}
LIMIT ${LIMIT}
`
return {
cursor: subs.length === LIMIT ? nextCursorEncoded(decodedCursor, LIMIT) : null,
subs
}
}
},
Mutation: {
upsertSub: async (parent, { ...data }, { me, models, lnd }) => {
upsertSub: async (parent, { hash, hmac, ...data }, { me, models, lnd }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
await validateSchema(territorySchema, data, { models, me, sub: { name: data.oldName } })
data.uploadIds = uploadIdsFromText(data.desc)
await ssValidate(territorySchema, data, { models, me, sub: { name: data.oldName } })
if (data.oldName) {
return await updateSub(parent, data, { me, models, lnd })
return await updateSub(parent, data, { me, models, lnd, hash, hmac })
} else {
return await createSub(parent, data, { me, models, lnd })
return await createSub(parent, data, { me, models, lnd, hash, hmac })
}
},
paySub: async (parent, { name }, { me, models, lnd }) => {
paySub: async (parent, { name, hash, hmac }, { me, models, lnd }) => {
// check that they own the sub
const sub = await models.sub.findUnique({
where: {
@ -228,22 +230,30 @@ export default {
})
if (!sub) {
throw new GqlInputError('sub not found')
throw new GraphQLError('sub not found', { extensions: { code: 'BAD_INPUT' } })
}
if (sub.userId !== me.id) {
throw new GqlInputError('you do not own this sub')
throw new GraphQLError('you do not own this sub', { extensions: { code: 'BAD_INPUT' } })
}
if (sub.status === 'ACTIVE') {
return sub
}
return await performPaidAction('TERRITORY_BILLING', { name }, { me, models, lnd })
const queries = paySubQueries(sub, models)
if (queries.length === 0) {
return sub
}
const results = await serialize(
queries,
{ models, lnd, me, hash, hmac, fee: sub.billingCost, verifyPayment: !!hash || !me })
return results[1]
},
toggleMuteSub: async (parent, { name }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
const lookupData = { userId: Number(me.id), subName: name }
@ -259,7 +269,7 @@ export default {
},
toggleSubSubscription: async (sub, { name }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
const lookupData = { userId: me.id, subName: name }
@ -275,7 +285,7 @@ export default {
},
transferTerritory: async (parent, { subName, userName }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
const sub = await models.sub.findUnique({
@ -284,18 +294,18 @@ export default {
}
})
if (!sub) {
throw new GqlInputError('sub not found')
throw new GraphQLError('sub not found', { extensions: { code: 'BAD_INPUT' } })
}
if (sub.userId !== me.id) {
throw new GqlInputError('you do not own this sub')
throw new GraphQLError('you do not own this sub', { extensions: { code: 'BAD_INPUT' } })
}
const user = await models.user.findFirst({ where: { name: userName } })
if (!user) {
throw new GqlInputError('user not found')
throw new GraphQLError('user not found', { extensions: { code: 'BAD_INPUT' } })
}
if (user.id === me.id) {
throw new GqlInputError('cannot transfer territory to yourself')
throw new GraphQLError('cannot transfer territory to yourself', { extensions: { code: 'BAD_INPUT' } })
}
const [, updatedSub] = await models.$transaction([
@ -307,30 +317,60 @@ export default {
return updatedSub
},
unarchiveTerritory: async (parent, { ...data }, { me, models, lnd }) => {
unarchiveTerritory: async (parent, { hash, hmac, ...data }, { me, models, lnd }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
const { name } = data
await validateSchema(territorySchema, data, { models, me })
await ssValidate(territorySchema, data, { models, me, sub: { name } })
const oldSub = await models.sub.findUnique({ where: { name } })
if (!oldSub) {
throw new GqlInputError('sub not found')
throw new GraphQLError('sub not found', { extensions: { code: 'BAD_INPUT' } })
}
if (oldSub.status !== 'STOPPED') {
throw new GqlInputError('sub is not archived')
throw new GraphQLError('sub is not archived', { extensions: { code: 'BAD_INPUT' } })
}
if (oldSub.billingType === 'ONCE') {
// sanity check. this should never happen but leaving this comment here
// to stop error propagation just in case and document that this should never happen.
// #defensivecode
throw new GqlInputError('sub should not be archived')
throw new GraphQLError('sub should not be archived', { extensions: { code: 'BAD_INPUT' } })
}
return await performPaidAction('TERRITORY_UNARCHIVE', data, { me, models, lnd })
const billingCost = TERRITORY_PERIOD_COST(data.billingType)
const billPaidUntil = nextBilling(new Date(), data.billingType)
const cost = BigInt(1000) * BigInt(billingCost)
const newSub = { ...data, billPaidUntil, billingCost, userId: me.id, status: 'ACTIVE' }
const isTransfer = oldSub.userId !== me.id
await serialize([
models.user.update({
where: {
id: me.id
},
data: {
msats: {
decrement: cost
}
}
}),
models.subAct.create({
data: {
subName: name,
userId: me.id,
msats: cost,
type: 'BILLING'
}
}),
models.sub.update({ where: { name }, data: newSub }),
isTransfer && models.territoryTransfer.create({ data: { subName: name, oldUserId: oldSub.userId, newUserId: me.id } })
],
{ models, lnd, hash, me, hmac, fee: billingCost, verifyPayment: !!hash || !me })
if (isTransfer) notifyTerritoryTransfer({ models, sub: newSub, to: me })
}
},
Sub: {
@ -368,18 +408,74 @@ export default {
}
}
async function createSub (parent, data, { me, models, lnd }) {
async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
const { billingType } = data
let billingCost = TERRITORY_COST_MONTHLY
const billedLastAt = new Date()
let billPaidUntil = datePivot(billedLastAt, { months: 1 })
if (billingType === 'ONCE') {
billingCost = TERRITORY_COST_ONCE
billPaidUntil = null
} else if (billingType === 'YEARLY') {
billingCost = TERRITORY_COST_YEARLY
billPaidUntil = datePivot(billedLastAt, { years: 1 })
}
const cost = BigInt(1000) * BigInt(billingCost)
try {
return await performPaidAction('TERRITORY_CREATE', data, { me, models, lnd })
const results = await serialize([
// bill 'em
models.user.update({
where: {
id: me.id
},
data: {
msats: {
decrement: cost
}
}
}),
// create 'em
models.sub.create({
data: {
...data,
billedLastAt,
billPaidUntil,
billingCost,
rankingType: 'WOT',
userId: me.id
}
}),
// record 'em
models.subAct.create({
data: {
userId: me.id,
subName: data.name,
msats: cost,
type: 'BILLING'
}
}),
// notify 'em (in the future)
models.subSubscription.create({
data: {
userId: me.id,
subName: data.name
}
})
], { models, lnd, me, hash, hmac, fee: billingCost, verifyPayment: !!hash || !me })
return results[1]
} catch (error) {
if (error.code === 'P2002') {
throw new GqlInputError('name taken')
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })
}
throw error
}
}
async function updateSub (parent, { oldName, ...data }, { me, models, lnd }) {
async function updateSub (parent, { oldName, ...data }, { me, models, lnd, hash, hmac }) {
const oldSub = await models.sub.findUnique({
where: {
name: oldName,
@ -393,14 +489,78 @@ async function updateSub (parent, { oldName, ...data }, { me, models, lnd }) {
})
if (!oldSub) {
throw new GqlInputError('sub not found')
throw new GraphQLError('sub not found', { extensions: { code: 'BAD_INPUT' } })
}
try {
return await performPaidAction('TERRITORY_UPDATE', { oldName, ...data }, { me, models, lnd })
// if the cost is changing, record the new cost and update billing job
if (oldSub.billingType !== data.billingType) {
// make sure the current cost is recorded so they are grandfathered in
data.billingCost = TERRITORY_PERIOD_COST(data.billingType)
// we never want to bill them again if they are changing to ONCE
if (data.billingType === 'ONCE') {
data.billPaidUntil = null
data.billingAutoRenew = false
}
// if they are changing to YEARLY, bill them in a year
// if they are changing to MONTHLY from YEARLY, do nothing
if (oldSub.billingType === 'MONTHLY' && data.billingType === 'YEARLY') {
data.billPaidUntil = datePivot(new Date(oldSub.billedLastAt), { years: 1 })
}
// if this billing change makes their bill paid up, set them to active
if (data.billPaidUntil === null || data.billPaidUntil >= new Date()) {
data.status = 'ACTIVE'
}
// if the billing type is changing such that it's more expensive, bill 'em the difference
const proratedCost = proratedBillingCost(oldSub, data.billingType)
if (proratedCost > 0) {
const cost = BigInt(1000) * BigInt(proratedCost)
const results = await serialize([
models.user.update({
where: {
id: me.id
},
data: {
msats: {
decrement: cost
}
}
}),
models.subAct.create({
data: {
userId: me.id,
subName: oldName,
msats: cost,
type: 'BILLING'
}
}),
models.sub.update({
data,
where: {
name: oldName,
userId: me.id
}
})
], { models, lnd, me, hash, hmac, fee: proratedCost, verifyPayment: !!hash || !me })
return results[2]
}
}
// if we get here they are changin in a way that doesn't cost them anything
return await models.sub.update({
data,
where: {
name: oldName,
userId: me.id
}
})
} catch (error) {
if (error.code === 'P2002') {
throw new GqlInputError('name taken')
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })
}
throw error
}

View File

@ -1,50 +1,27 @@
import { USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW, AWS_S3_URL_REGEXP, AVATAR_TYPES_ALLOW } from '@/lib/constants'
import { GraphQLError } from 'graphql'
import { USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW } from '@/lib/constants'
import { createPresignedPost } from '@/api/s3'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { msatsToSats } from '@/lib/format'
export default {
Query: {
uploadFees: async (parent, { s3Keys }, { models, me }) => {
const fees = await uploadFees(s3Keys, { models, me })
// GraphQL doesn't support bigint
return {
totalFees: Number(fees.totalFees),
totalFeesMsats: Number(fees.totalFeesMsats),
uploadFees: Number(fees.uploadFees),
uploadFeesMsats: Number(fees.uploadFeesMsats),
nUnpaid: Number(fees.nUnpaid),
bytesUnpaid: Number(fees.bytesUnpaid),
bytes24h: Number(fees.bytes24h)
}
}
},
Mutation: {
getSignedPOST: async (parent, { type, size, width, height, avatar }, { models, me }) => {
if (UPLOAD_TYPES_ALLOW.indexOf(type) === -1) {
throw new GqlInputError(`upload must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace(/^(image|video)\//, '')).join(', ')}`)
throw new GraphQLError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`, { extensions: { code: 'BAD_INPUT' } })
}
if (size > UPLOAD_SIZE_MAX) {
throw new GqlInputError(`upload must be less than ${UPLOAD_SIZE_MAX / (1024 ** 2)} megabytes`)
throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX / (1024 ** 2)} megabytes`, { extensions: { code: 'BAD_INPUT' } })
}
if (avatar) {
if (AVATAR_TYPES_ALLOW.indexOf(type) === -1) {
throw new GqlInputError(`avatar must be ${AVATAR_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`)
}
if (size > UPLOAD_SIZE_MAX_AVATAR) {
throw new GqlInputError(`avatar must be less than ${UPLOAD_SIZE_MAX_AVATAR / (1024 ** 2)} megabytes`)
}
if (avatar && size > UPLOAD_SIZE_MAX_AVATAR) {
throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX_AVATAR / (1024 ** 2)} megabytes`, { extensions: { code: 'BAD_INPUT' } })
}
// width and height is 0 for videos
if (width * height > IMAGE_PIXELS_MAX) {
throw new GqlInputError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`)
throw new GraphQLError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`, { extensions: { code: 'BAD_INPUT' } })
}
const fileParams = {
const imgParams = {
type,
size,
width,
@ -54,46 +31,12 @@ export default {
}
if (avatar) {
if (!me) throw new GqlAuthenticationError()
fileParams.paid = undefined
if (!me) throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
imgParams.paid = undefined
}
const upload = await models.upload.create({ data: { ...fileParams } })
const upload = await models.upload.create({ data: { ...imgParams } })
return createPresignedPost({ key: String(upload.id), type, size })
}
}
}
export function uploadIdsFromText (text) {
if (!text) return []
return [...new Set([...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])))]
}
export async function uploadFees (s3Keys, { models, me }) {
const [{
bytes24h,
bytesUnpaid,
nUnpaid,
uploadFeesMsats
}] = await models.$queryRaw`SELECT * FROM upload_fees(${me?.id ?? USER_ID.anon}::INTEGER, ${s3Keys}::INTEGER[])`
const uploadFees = BigInt(msatsToSats(uploadFeesMsats))
const totalFeesMsats = BigInt(nUnpaid) * uploadFeesMsats
const totalFees = BigInt(msatsToSats(totalFeesMsats))
return { bytes24h, bytesUnpaid, nUnpaid, uploadFees, uploadFeesMsats, totalFees, totalFeesMsats }
}
export async function throwOnExpiredUploads (uploadIds, { tx }) {
if (uploadIds.length === 0) return
const existingUploads = await tx.upload.findMany({
where: { id: { in: uploadIds } },
select: { id: true }
})
const existingIds = new Set(existingUploads.map(upload => upload.id))
const deletedIds = uploadIds.filter(id => !existingIds.has(id))
if (deletedIds.length > 0) {
throw new Error(`upload(s) ${deletedIds.join(', ')} are expired, consider reuploading.`)
}
}

View File

@ -1,17 +1,16 @@
import { readFile } from 'fs/promises'
import { join, resolve } from 'path'
import { GraphQLError } from 'graphql'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { msatsToSats } from '@/lib/format'
import { bioSchema, emailSchema, settingsSchema, validateSchema, userSchema } from '@/lib/validate'
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause, activeOrMine } from './item'
import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, INVOICE_ACTION_NOTIFICATION_TYPES, WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '@/lib/validate'
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause } from './item'
import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS } from '@/lib/constants'
import { viewGroup } from './growth'
import { datePivot, timeUnitForRange, whenRange } from '@/lib/time'
import { timeUnitForRange, whenRange } from '@/lib/time'
import assertApiKeyNotPermitted from './apiKey'
import { hashEmail } from '@/lib/crypto'
import { isMuted } from '@/lib/user'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
import { processCrop } from '@/worker/imgproxy'
const contributors = new Set()
@ -67,12 +66,11 @@ export async function topUsers (parent, { cursor, when, by, from, to, limit = LI
case 'comments': column = 'ncomments'; break
case 'referrals': column = 'referrals'; break
case 'stacking': column = 'stacked'; break
case 'value':
default: column = 'proportion'; break
}
const users = (await models.$queryRawUnsafe(`
SELECT * ${column === 'proportion' ? ', proportion' : ''}
SELECT *
FROM
(SELECT users.*,
COALESCE(floor(sum(msats_spent)/1000), 0) as spent,
@ -127,14 +125,13 @@ export default {
},
settings: async (parent, args, { models, me }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
return await models.user.findUnique({ where: { id: me.id } })
},
user: async (parent, { id, name }, { models }) => {
if (id) id = Number(id)
return await models.user.findUnique({ where: { id, name } })
user: async (parent, { name }, { models }) => {
return await models.user.findUnique({ where: { name } })
},
users: async (parent, args, { models }) =>
await models.user.findMany(),
@ -147,7 +144,7 @@ export default {
},
mySubscribedUsers: async (parent, { cursor }, { models, me }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('You must be logged in to view subscribed users', { extensions: { code: 'UNAUTHENTICATED' } })
}
const decodedCursor = decodeCursor(cursor)
@ -168,7 +165,7 @@ export default {
},
myMutedUsers: async (parent, { cursor }, { models, me }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('You must be logged in to view muted users', { extensions: { code: 'UNAUTHENTICATED' } })
}
const decodedCursor = decodeCursor(cursor)
@ -286,8 +283,6 @@ export default {
'"ThreadSubscription"."userId" = $1',
'r.created_at > $2',
'r.created_at >= "ThreadSubscription".created_at',
'r."userId" <> $1',
activeOrMine(me),
await filterClause(me, models),
muteClause(me),
...(user.noteAllDescendants ? [] : ['r.level = 1'])
@ -309,7 +304,6 @@ export default {
("Item"."parentId" IS NULL AND "UserSubscription"."postsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."postsSubscribedAt")
OR ("Item"."parentId" IS NOT NULL AND "UserSubscription"."commentsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."commentsSubscribedAt")
)`,
activeOrMine(me),
await filterClause(me, models),
muteClause(me))})`, me.id, lastChecked)
if (newUserSubs.exists) {
@ -326,8 +320,6 @@ export default {
'"SubSubscription"."userId" = $1',
'"Item".created_at > $2',
'"Item"."parentId" IS NULL',
'"Item"."userId" <> $1',
activeOrMine(me),
await filterClause(me, models),
muteClause(me))})`, me.id, lastChecked)
if (newSubPost.exists) {
@ -346,7 +338,6 @@ export default {
'"Mention"."userId" = $1',
'"Mention".created_at > $2',
'"Item"."userId" <> $1',
activeOrMine(me),
await filterClause(me, models),
muteClause(me)
)})`, me.id, lastChecked)
@ -367,7 +358,6 @@ export default {
'"ItemMention".created_at > $2',
'"Item"."userId" <> $1',
'"Referee"."userId" = $1',
activeOrMine(me),
await filterClause(me, models),
muteClause(me)
)})`, me.id, lastChecked)
@ -385,19 +375,30 @@ export default {
JOIN "ItemForward" ON
"ItemForward"."itemId" = "Item".id
AND "ItemForward"."userId" = $1
${whereClause(
'"Item"."lastZapAt" > $2',
'"Item"."userId" <> $1',
activeOrMine(me),
await filterClause(me, models),
muteClause(me)
)})`, me.id, lastChecked)
WHERE "Item"."lastZapAt" > $2
AND "Item"."userId" <> $1)`, me.id, lastChecked)
if (newFwdSats.exists) {
foundNotes()
return true
}
}
const job = await models.item.findFirst({
where: {
maxBid: {
not: null
},
userId: me.id,
statusUpdatedAt: {
gt: lastChecked
}
}
})
if (job && job.statusUpdatedAt > job.createdAt) {
foundNotes()
return true
}
if (user.noteEarning) {
const earn = await models.earn.findFirst({
where: {
@ -423,16 +424,7 @@ export default {
confirmedAt: {
gt: lastChecked
},
OR: [
{
isHeld: null,
actionType: null
},
{
actionType: 'RECEIVE',
actionState: 'PAID'
}
]
isHeld: null
}
})
if (invoice) {
@ -446,13 +438,9 @@ export default {
where: {
userId: me.id,
status: 'CONFIRMED',
hash: {
not: null
},
updatedAt: {
gt: lastChecked
},
invoiceForward: { is: null }
}
}
})
if (wdrwl) {
@ -535,59 +523,6 @@ export default {
return true
}
const invoiceActionFailed = await models.invoice.findFirst({
where: {
userId: me.id,
updatedAt: {
gt: lastChecked
},
actionType: {
in: INVOICE_ACTION_NOTIFICATION_TYPES
},
actionState: 'FAILED',
OR: [
{
paymentAttempt: {
gte: WALLET_MAX_RETRIES
}
},
{
userCancel: true
}
]
}
})
if (invoiceActionFailed) {
foundNotes()
return true
}
const invoiceActionFailed2 = await models.invoice.findFirst({
where: {
userId: me.id,
updatedAt: {
gt: datePivot(lastChecked, { milliseconds: -WALLET_RETRY_BEFORE_MS })
},
actionType: {
in: INVOICE_ACTION_NOTIFICATION_TYPES
},
actionState: 'FAILED',
paymentAttempt: {
lt: WALLET_MAX_RETRIES
},
userCancel: false,
cancelledAt: {
lte: datePivot(new Date(), { milliseconds: -WALLET_RETRY_BEFORE_MS })
}
}
})
if (invoiceActionFailed2) {
foundNotes()
return true
}
// update checkedNotesAt to prevent rechecking same time period
models.user.update({
where: { id: me.id },
@ -614,8 +549,7 @@ export default {
json_build_object('name', 'comments', 'value', COALESCE(SUM(comments), 0)),
json_build_object('name', 'posts', 'value', COALESCE(SUM(posts), 0)),
json_build_object('name', 'territories', 'value', COALESCE(SUM(territories), 0)),
json_build_object('name', 'referrals', 'value', COALESCE(SUM(referrals), 0)),
json_build_object('name', 'one day referrals', 'value', COALESCE(SUM(one_day_referrals), 0))
json_build_object('name', 'referrals', 'value', COALESCE(SUM(referrals), 0))
) AS data
FROM ${viewGroup(range, 'user_stats')}
WHERE id = ${me.id}
@ -630,7 +564,6 @@ export default {
json_build_object('name', 'zaps', 'value', ROUND(COALESCE(SUM(msats_tipped), 0) / 1000)),
json_build_object('name', 'rewards', 'value', ROUND(COALESCE(SUM(msats_rewards), 0) / 1000)),
json_build_object('name', 'referrals', 'value', ROUND( COALESCE(SUM(msats_referrals), 0) / 1000)),
json_build_object('name', 'one day referrals', 'value', ROUND( COALESCE(SUM(msats_one_day_referrals), 0) / 1000)),
json_build_object('name', 'territories', 'value', ROUND(COALESCE(SUM(msats_revenue), 0) / 1000))
) AS data
FROM ${viewGroup(range, 'user_stats')}
@ -644,7 +577,6 @@ export default {
SELECT date_trunc('${timeUnitForRange(range)}', t) at time zone 'America/Chicago' as time,
json_build_array(
json_build_object('name', 'fees', 'value', FLOOR(COALESCE(SUM(msats_fees), 0) / 1000)),
json_build_object('name', 'zapping', 'value', FLOOR(COALESCE(SUM(msats_zaps), 0) / 1000)),
json_build_object('name', 'donations', 'value', FLOOR(COALESCE(SUM(msats_donated), 0) / 1000)),
json_build_object('name', 'territories', 'value', FLOOR(COALESCE(SUM(msats_billing), 0) / 1000))
) AS data
@ -656,49 +588,29 @@ export default {
},
Mutation: {
disableFreebies: async (parent, args, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
// disable freebies if it hasn't been set yet
try {
await models.user.update({
where: { id: me.id, disableFreebies: null },
data: { disableFreebies: true }
})
} catch (err) {
// ignore 'record not found' errors
if (err.code !== 'P2025') {
throw err
}
}
return true
},
setName: async (parent, data, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
await validateSchema(userSchema, data, { models })
await ssValidate(userSchema, data, { models })
try {
await models.user.update({ where: { id: me.id }, data })
return data.name
} catch (error) {
if (error.code === 'P2002') {
throw new GqlInputError('name taken')
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })
}
throw error
}
},
setSettings: async (parent, { settings: { nostrRelays, ...data } }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
await validateSchema(settingsSchema, { nostrRelays, ...data })
await ssValidate(settingsSchema, { nostrRelays, ...data })
if (nostrRelays?.length) {
const connectOrCreate = []
@ -721,28 +633,16 @@ export default {
},
setWalkthrough: async (parent, { upvotePopover, tipPopover }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
await models.user.update({ where: { id: me.id }, data: { upvotePopover, tipPopover } })
return true
},
cropPhoto: async (parent, { photoId, cropData }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const croppedUrl = await processCrop({ photoId: Number(photoId), cropData })
if (!croppedUrl) {
throw new GqlInputError('can\'t crop photo')
}
return croppedUrl
},
setPhoto: async (parent, { photoId }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
await models.user.update({
@ -752,29 +652,31 @@ export default {
return Number(photoId)
},
upsertBio: async (parent, { text }, { me, models, lnd }) => {
upsertBio: async (parent, { bio }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
await validateSchema(bioSchema, { text })
await ssValidate(bioSchema, { bio })
const user = await models.user.findUnique({ where: { id: me.id } })
if (user.bioId) {
return await updateItem(parent, { id: user.bioId, bio: true, text, title: `@${user.name}'s bio` }, { me, models, lnd })
await updateItem(parent, { id: user.bioId, text: bio, title: `@${user.name}'s bio` }, { me, models })
} else {
return await createItem(parent, { bio: true, text, title: `@${user.name}'s bio` }, { me, models, lnd })
await createItem(parent, { bio: true, text: bio, title: `@${user.name}'s bio` }, { me, models })
}
return await models.user.findUnique({ where: { id: me.id } })
},
generateApiKey: async (parent, { id }, { models, me }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
const user = await models.user.findUnique({ where: { id: me.id } })
if (!user.apiKeyEnabled) {
throw new GqlAuthorizationError('you are not allowed to generate api keys')
throw new GraphQLError('you are not allowed to generate api keys', { extensions: { code: 'FORBIDDEN' } })
}
// I trust postgres CSPRNG more than the one from JS
@ -789,14 +691,14 @@ export default {
},
deleteApiKey: async (parent, { id }, { models, me }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
return await models.user.update({ where: { id: me.id }, data: { apiKeyHash: null } })
},
unlinkAuth: async (parent, { authType }, { models, me }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
assertApiKeyNotPermitted({ me })
@ -805,7 +707,7 @@ export default {
user = await models.user.findUnique({ where: { id: me.id } })
const account = await models.account.findFirst({ where: { userId: me.id, provider: authType } })
if (!account) {
throw new GqlInputError('no such account')
throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } })
}
await models.account.delete({ where: { id: account.id } })
if (authType === 'twitter') {
@ -820,18 +722,18 @@ export default {
} else if (authType === 'email') {
user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null, emailHash: null } })
} else {
throw new GqlInputError('no such account')
throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } })
}
return await authMethods(user, undefined, { models, me })
},
linkUnverifiedEmail: async (parent, { email }, { models, me }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
assertApiKeyNotPermitted({ me })
await validateSchema(emailSchema, { email })
await ssValidate(emailSchema, { email })
try {
await models.user.update({
@ -840,7 +742,7 @@ export default {
})
} catch (error) {
if (error.code === 'P2002') {
throw new GqlInputError('email taken')
throw new GraphQLError('email taken', { extensions: { code: 'BAD_INPUT' } })
}
throw error
}
@ -853,12 +755,12 @@ export default {
const muted = await isMuted({ models, muterId: me?.id, mutedId: id })
if (existing) {
if (muted && !existing.postsSubscribedAt) {
throw new GqlInputError("you can't subscribe to a stacker that you've muted")
throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } })
}
await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { postsSubscribedAt: existing.postsSubscribedAt ? null : new Date() } })
} else {
if (muted) {
throw new GqlInputError("you can't subscribe to a stacker that you've muted")
throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } })
}
await models.userSubscription.create({ data: { ...lookupData, postsSubscribedAt: new Date() } })
}
@ -870,12 +772,12 @@ export default {
const muted = await isMuted({ models, muterId: me?.id, mutedId: id })
if (existing) {
if (muted && !existing.commentsSubscribedAt) {
throw new GqlInputError("you can't subscribe to a stacker that you've muted")
throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } })
}
await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { commentsSubscribedAt: existing.commentsSubscribedAt ? null : new Date() } })
} else {
if (muted) {
throw new GqlInputError("you can't subscribe to a stacker that you've muted")
throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } })
}
await models.userSubscription.create({ data: { ...lookupData, commentsSubscribedAt: new Date() } })
}
@ -898,7 +800,7 @@ export default {
}
})
if (subscription?.postsSubscribedAt || subscription?.commentsSubscribedAt) {
throw new GqlInputError("you can't mute a stacker to whom you've subscribed")
throw new GraphQLError("you can't mute a stacker to whom you've subscribed", { extensions: { code: 'BAD_INPUT' } })
}
await models.mute.create({ data: { ...lookupData } })
}
@ -906,27 +808,11 @@ export default {
},
hideWelcomeBanner: async (parent, data, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
await models.user.update({ where: { id: me.id }, data: { hideWelcomeBanner: true } })
return true
},
hideWalletRecvPrompt: async (parent, data, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
await models.user.update({ where: { id: me.id }, data: { hideWalletRecvPrompt: true } })
return true
},
setDiagnostics: async (parent, { diagnostics }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
await models.user.update({ where: { id: me.id }, data: { diagnostics } })
return diagnostics
}
},
@ -979,8 +865,7 @@ export default {
// get the user's first item
const item = await models.item.findFirst({
where: {
userId: user.id,
OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }]
userId: user.id
},
orderBy: {
createdAt: 'asc'
@ -1000,8 +885,7 @@ export default {
createdAt: {
gte,
lte
},
OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }]
}
}
})
},
@ -1018,8 +902,7 @@ export default {
createdAt: {
gte,
lte
},
OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }]
}
}
})
},
@ -1036,8 +919,7 @@ export default {
createdAt: {
gte,
lte
},
OR: [{ invoiceActionState: 'PAID' }, { invoiceActionState: null }]
}
}
})
},
@ -1068,13 +950,7 @@ export default {
if (!me || me.id !== user.id) {
return 0
}
return msatsToSats(user.msats + user.mcredits)
},
credits: async (user, args, { models, me }) => {
if (!me || me.id !== user.id) {
return 0
}
return msatsToSats(user.mcredits)
return msatsToSats(user.msats)
},
authMethods,
hasInvites: async (user, args, { models }) => {
@ -1094,15 +970,6 @@ export default {
})
return relays?.map(r => r.nostrRelayAddr)
},
tipRandom: async (user, args, { me }) => {
if (!me || me.id !== user.id) {
return false
}
return !!user.tipRandomMin && !!user.tipRandomMax
},
hideWalletRecvPrompt: async (user, args, { models }) => {
return user.hideWalletRecvPrompt || user.hasRecvWallet
}
},
@ -1114,27 +981,14 @@ export default {
return user.streak
},
hasSendWallet: async (user, args, { models }) => {
if (user.hideCowboyHat) {
return false
}
return user.hasSendWallet
},
hasRecvWallet: async (user, args, { models }) => {
if (user.hideCowboyHat) {
return false
}
return user.hasRecvWallet
},
maxStreak: async (user, args, { models }) => {
if (user.hideCowboyHat) {
return null
}
const [{ max }] = await models.$queryRaw`
SELECT MAX(COALESCE("endedAt"::date, (now() AT TIME ZONE 'America/Chicago')::date) - "startedAt"::date)
FROM "Streak" WHERE "userId" = ${user.id}
AND type = 'COWBOY_HAT'`
SELECT MAX(COALESCE("endedAt", (now() AT TIME ZONE 'America/Chicago')::date) - "startedAt")
FROM "Streak" WHERE "userId" = ${user.id}`
return max
},
isContributor: async (user, args, { me }) => {
@ -1158,7 +1012,7 @@ export default {
if (!when || when === 'forever') {
// forever
return ((user.stackedMsats && msatsToSats(user.stackedMsats)) || 0)
return (user.stackedMsats && msatsToSats(user.stackedMsats)) || 0
}
const range = whenRange(when, from, to)

View File

@ -1,47 +1,54 @@
import {
getInvoice as getInvoiceFromLnd, deletePayment, getPayment,
parsePaymentRequest
} from 'ln-service'
import crypto, { timingSafeEqual } from 'crypto'
import { getIdentity, createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice, getInvoice as getInvoiceFromLnd, getNode, authenticatedLndGrpc, deletePayment, getPayment } from 'ln-service'
import { GraphQLError } from 'graphql'
import crypto from 'crypto'
import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT, itemQueryWithMeta } from './item'
import { msatsToSats, msatsToSatsDecimal } from '@/lib/format'
import {
USER_ID, INVOICE_RETENTION_DAYS,
WALLET_RETRY_AFTER_MS,
WALLET_RETRY_BEFORE_MS,
WALLET_MAX_RETRIES
} from '@/lib/constants'
import { validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
import { SELECT } from './item'
import { lnAddrOptions } from '@/lib/lnurl'
import { msatsToSats, msatsToSatsDecimal, ensureB64 } from '@/lib/format'
import { CLNAutowithdrawSchema, LNDAutowithdrawSchema, amountSchema, lnAddrAutowithdrawSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '@/lib/validate'
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, Wallet } from '@/lib/constants'
import { datePivot } from '@/lib/time'
import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey'
import { createInvoice as createInvoiceCLN } from '@/lib/cln'
import { bolt11Tags } from '@/lib/bolt11'
import { finalizeHodlInvoice } from '@/worker/wallet'
import { lnAddrOptions } from '@/lib/lnurl'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
import { getNodeSockets } from '../lnd'
import performPaidAction from '../paidAction'
import performPayingAction from '../payingAction'
export async function getInvoice (parent, { id }, { me, models, lnd }) {
const inv = await models.invoice.findUnique({
where: {
id: Number(id)
},
include: {
user: true
}
})
if (!inv) {
throw new GqlInputError('invoice not found')
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
}
if (inv.userId === USER_ID.anon) {
if (inv.user.id === USER_ID.anon) {
return inv
}
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
if (inv.userId !== me.id) {
throw new GqlInputError('not ur invoice')
if (inv.user.id !== me.id) {
throw new GraphQLError('not ur invoice', { extensions: { code: 'FORBIDDEN' } })
}
try {
inv.nostr = JSON.parse(inv.desc)
} catch (err) {
}
try {
if (inv.confirmedAt) {
inv.confirmedPreimage = (await getInvoiceFromLnd({ id: inv.hash, lnd })).secret
}
} catch (err) {
console.error('error fetching invoice from LND', err)
}
return inv
@ -49,60 +56,85 @@ export async function getInvoice (parent, { id }, { me, models, lnd }) {
export async function getWithdrawl (parent, { id }, { me, models, lnd }) {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
const wdrwl = await models.withdrawl.findUnique({
where: {
id: Number(id)
},
include: {
user: true
}
})
if (!wdrwl) {
throw new GqlInputError('withdrawal not found')
throw new GraphQLError('withdrawal not found', { extensions: { code: 'BAD_INPUT' } })
}
if (wdrwl.userId !== me.id) {
throw new GqlInputError('not ur withdrawal')
if (wdrwl.user.id !== me.id) {
throw new GraphQLError('not ur withdrawal', { extensions: { code: 'FORBIDDEN' } })
}
try {
if (wdrwl.status === 'CONFIRMED') {
wdrwl.preimage = (await getPayment({ id: wdrwl.hash, lnd })).payment.secret
}
} catch (err) {
console.error('error fetching payment from LND', err)
}
return wdrwl
}
export function createHmac (hash) {
if (!hash) throw new GqlInputError('hash required to create hmac')
const key = Buffer.from(process.env.INVOICE_HMAC_KEY, 'hex')
return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex')
}
export function verifyHmac (hash, hmac) {
if (!hash || !hmac) throw new GqlInputError('hash or hmac missing')
const hmac2 = createHmac(hash)
if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) {
throw new GqlAuthorizationError('bad hmac')
}
return true
}
const resolvers = {
export default {
Query: {
invoice: getInvoice,
withdrawl: getWithdrawl,
direct: async (parent, { id }, { me, models }) => {
wallet: async (parent, { id }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
return await models.directPayment.findUnique({
return await models.wallet.findUnique({
where: {
id: Number(id),
receiverId: me.id
userId: me.id,
id: Number(id)
}
})
},
walletByType: async (parent, { type }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
const wallet = await models.wallet.findFirst({
where: {
userId: me.id,
type
}
})
return wallet
},
wallets: async (parent, args, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
return await models.wallet.findMany({
where: {
userId: me.id
}
})
},
withdrawl: getWithdrawl,
numBolt11s: async (parent, args, { me, models, lnd }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
return await models.withdrawl.count({
@ -118,7 +150,7 @@ const resolvers = {
walletHistory: async (parent, { cursor, inc }, { me, models, lnd }) => {
const decodedCursor = decodeCursor(cursor)
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
const include = new Set(inc?.split(','))
@ -132,8 +164,8 @@ const resolvers = {
jsonb_build_object(
'bolt11', bolt11,
'status', CASE WHEN "confirmedAt" IS NOT NULL THEN 'CONFIRMED'
WHEN "expiresAt" <= $2 THEN 'EXPIRED'
WHEN cancelled THEN 'CANCELLED'
WHEN "expiresAt" <= $2 AND NOT "isHeld" THEN 'EXPIRED'
ELSE 'PENDING' END,
'description', "desc",
'invoiceComment', comment,
@ -147,30 +179,17 @@ const resolvers = {
if (include.has('withdrawal')) {
queries.push(
`(SELECT
"Withdrawl".id, "Withdrawl".created_at as "createdAt",
id, created_at as "createdAt",
COALESCE("msatsPaid", "msatsPaying") as msats,
CASE WHEN bool_and("InvoiceForward".id IS NULL) THEN 'withdrawal' ELSE 'p2p' END as type,
'withdrawal' as type,
jsonb_build_object(
'bolt11', "Withdrawl".bolt11,
'bolt11', bolt11,
'autoWithdraw', "autoWithdraw",
'status', COALESCE(status::text, 'PENDING'),
'msatsFee', COALESCE("msatsFeePaid", "msatsFeePaying")) as other
FROM "Withdrawl"
LEFT JOIN "InvoiceForward" ON "Withdrawl".id = "InvoiceForward"."withdrawlId"
WHERE "Withdrawl"."userId" = $1
AND "Withdrawl".created_at <= $2
GROUP BY "Withdrawl".id)`
)
queries.push(
`(SELECT id, created_at as "createdAt", msats, 'direct' as type,
jsonb_build_object(
'bolt11', bolt11,
'description', "desc",
'invoiceComment', comment,
'invoicePayerData', "lud18Data") as other
FROM "DirectPayment"
WHERE "DirectPayment"."receiverId" = $1
AND "DirectPayment".created_at <= $2)`
WHERE "userId" = $1
AND created_at <= $2)`
)
}
@ -196,7 +215,6 @@ const resolvers = {
WHERE "ItemAct".act = 'TIP'
AND ("Item"."userId" = $1 OR "ItemForward"."userId" = $1)
AND "ItemAct".created_at <= $2
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
GROUP BY "Item".id)`
)
queries.push(
@ -229,7 +247,6 @@ const resolvers = {
JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE "ItemAct"."userId" = $1
AND "ItemAct".created_at <= $2
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
GROUP BY "Item".id)`
)
queries.push(
@ -272,9 +289,6 @@ const resolvers = {
case 'withdrawal':
f.msats = (-1 * Number(f.msats)) - Number(f.msatsFee)
break
case 'p2p':
f.msats = -1 * Number(f.msats)
break
case 'spent':
case 'donation':
case 'billing':
@ -292,69 +306,114 @@ const resolvers = {
facts: history
}
},
failedInvoices: async (parent, args, { me, models }) => {
walletLogs: async (parent, args, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
return await models.$queryRaw`
SELECT * FROM "Invoice"
WHERE "userId" = ${me.id}
AND "actionState" = 'FAILED'
-- never retry if user has cancelled the invoice manually
AND "userCancel" = false
AND "cancelledAt" < now() - ${`${WALLET_RETRY_AFTER_MS} milliseconds`}::interval
AND "cancelledAt" > now() - ${`${WALLET_RETRY_BEFORE_MS} milliseconds`}::interval
AND "paymentAttempt" < ${WALLET_MAX_RETRIES}
AND (
"actionType" = 'ITEM_CREATE' OR
"actionType" = 'ZAP' OR
"actionType" = 'DOWN_ZAP' OR
"actionType" = 'POLL_VOTE' OR
"actionType" = 'BOOST'
)
ORDER BY id DESC`
return await models.walletLog.findMany({
where: {
userId: me.id
},
orderBy: {
createdAt: 'asc'
}
})
}
},
InvoiceOrDirect: {
__resolveType: invoiceOrDirect => invoiceOrDirect.__resolveType
WalletDetails: {
__resolveType (wallet) {
return wallet.address ? 'WalletLNAddr' : wallet.macaroon ? 'WalletLND' : 'WalletCLN'
}
},
Mutation: {
createInvoice: async (parent, { amount, hodlInvoice = false, expireSecs = 3600 }, { me, models, lnd, headers }) => {
await ssValidate(amountSchema, { amount })
await assertGofacYourself({ models, headers })
let expirePivot = { seconds: expireSecs }
let invLimit = INV_PENDING_LIMIT
let balanceLimit = (hodlInvoice || USER_IDS_BALANCE_NO_LIMIT.includes(Number(me?.id))) ? 0 : BALANCE_LIMIT_MSATS
let id = me?.id
if (!me) {
expirePivot = { seconds: Math.min(expireSecs, 180) }
invLimit = ANON_INV_PENDING_LIMIT
balanceLimit = ANON_BALANCE_LIMIT_MSATS
id = USER_ID.anon
}
const user = await models.user.findUnique({ where: { id } })
const expiresAt = datePivot(new Date(), expirePivot)
const description = `Funding @${user.name} on stacker.news`
try {
const invoice = await (hodlInvoice ? createHodlInvoice : createInvoice)({
description: user.hideInvoiceDesc ? undefined : description,
lnd,
tokens: amount,
expires_at: expiresAt
})
const [inv] = await serialize(
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${hodlInvoice ? invoice.secret : null}::TEXT, ${invoice.request},
${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description}, NULL, NULL,
${invLimit}::INTEGER, ${balanceLimit})`,
{ models }
)
// the HMAC is only returned during invoice creation
// this makes sure that only the person who created this invoice
// has access to the HMAC
const hmac = createHmac(inv.hash)
return { ...inv, hmac }
} catch (error) {
console.log(error)
throw error
}
},
createWithdrawl: createWithdrawal,
sendToLnAddr,
cancelInvoice: async (parent, { hash, hmac, userCancel }, { me, models, lnd, boss }) => {
// stackers can cancel their own invoices without hmac
if (me && !hmac) {
const inv = await models.invoice.findUnique({ where: { hash } })
if (!inv) throw new GqlInputError('invoice not found')
if (inv.userId !== me.id) throw new GqlInputError('not ur invoice')
} else {
verifyHmac(hash, hmac)
cancelInvoice: async (parent, { hash, hmac }, { models, lnd }) => {
const hmac2 = createHmac(hash)
if (hmac !== hmac2) {
throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } })
}
await finalizeHodlInvoice({ data: { hash }, lnd, models, boss })
return await models.invoice.update({ where: { hash }, data: { userCancel: !!userCancel } })
await cancelHodlInvoice({ id: hash, lnd })
const inv = await serialize(
models.invoice.update({
where: {
hash
},
data: {
cancelled: true
}
}),
{ models }
)
return inv
},
dropBolt11: async (parent, { hash }, { me, models, lnd }) => {
dropBolt11: async (parent, { id }, { me, models, lnd }) => {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
const retention = `${INVOICE_RETENTION_DAYS} days`
const [invoice] = await models.$queryRaw`
WITH to_be_updated AS (
SELECT id, hash, bolt11
FROM "Withdrawl"
WHERE "userId" = ${me.id}
AND hash = ${hash}
AND now() > created_at + ${retention}::INTERVAL
AND hash IS NOT NULL
AND status IS NOT NULL
), updated_rows AS (
UPDATE "Withdrawl"
SET hash = NULL, bolt11 = NULL, preimage = NULL
FROM to_be_updated
WHERE "Withdrawl".id = to_be_updated.id)
SELECT * FROM to_be_updated;`
WITH to_be_updated AS (
SELECT id, hash, bolt11
FROM "Withdrawl"
WHERE "userId" = ${me.id}
AND id = ${Number(id)}
AND now() > created_at + interval '${retention}'
AND hash IS NOT NULL
), updated_rows AS (
UPDATE "Withdrawl"
SET hash = NULL, bolt11 = NULL
FROM to_be_updated
WHERE "Withdrawl".id = to_be_updated.id)
SELECT * FROM to_be_updated;`
if (invoice) {
try {
@ -363,150 +422,130 @@ const resolvers = {
console.error(error)
await models.withdrawl.update({
where: { id: invoice.id },
data: { hash: invoice.hash, bolt11: invoice.bolt11, preimage: invoice.preimage }
data: { hash: invoice.hash, bolt11: invoice.bolt11 }
})
throw new GqlInputError('failed to drop bolt11 from lnd')
throw new GraphQLError('failed to drop bolt11 from lnd', { extensions: { code: 'BAD_INPUT' } })
}
}
return { id }
},
upsertWalletLND: async (parent, { settings, ...data }, { me, models }) => {
// make sure inputs are base64
data.macaroon = ensureB64(data.macaroon)
data.cert = ensureB64(data.cert)
await models.$queryRaw`
UPDATE "DirectPayment"
SET hash = NULL, bolt11 = NULL, preimage = NULL
WHERE "receiverId" = ${me.id}
AND hash = ${hash}
AND now() > created_at + ${retention}::INTERVAL
AND hash IS NOT NULL`
const wallet = Wallet.LND
return await upsertWallet(
{
schema: LNDAutowithdrawSchema,
wallet,
testConnect: async ({ cert, macaroon, socket }) => {
try {
const { lnd } = await authenticatedLndGrpc({
cert,
macaroon,
socket
})
const inv = await createInvoice({
description: 'SN connection test',
lnd,
tokens: 0,
expires_at: new Date()
})
// we wrap both calls in one try/catch since connection attempts happen on RPC calls
await addWalletLog({ wallet, level: 'SUCCESS', message: 'connected to LND' }, { me, models })
return inv
} catch (err) {
// LND errors are in this shape: [code, type, { err: { code, details, metadata } }]
const details = err[2]?.err?.details || err.message || err.toString?.()
await addWalletLog({ wallet, level: 'ERROR', message: `could not connect to LND: ${details}` }, { me, models })
throw err
}
}
},
{ settings, data }, { me, models })
},
upsertWalletCLN: async (parent, { settings, ...data }, { me, models }) => {
data.cert = ensureB64(data.cert)
const wallet = Wallet.CLN
return await upsertWallet(
{
schema: CLNAutowithdrawSchema,
wallet,
testConnect: async ({ socket, rune, cert }) => {
try {
const inv = await createInvoiceCLN({
socket,
rune,
cert,
description: 'SN connection test',
msats: 'any',
expiry: 0
})
await addWalletLog({ wallet, level: 'SUCCESS', message: 'connected to CLN' }, { me, models })
return inv
} catch (err) {
const details = err.details || err.message || err.toString?.()
await addWalletLog({ wallet, level: 'ERROR', message: `could not connect to CLN: ${details}` }, { me, models })
throw err
}
}
},
{ settings, data }, { me, models })
},
upsertWalletLNAddr: async (parent, { settings, ...data }, { me, models }) => {
const wallet = Wallet.LnAddr
return await upsertWallet(
{
schema: lnAddrAutowithdrawSchema,
wallet,
testConnect: async ({ address }) => {
const options = await lnAddrOptions(address)
await addWalletLog({ wallet, level: 'SUCCESS', message: 'fetched payment details' }, { me, models })
return options
}
},
{ settings, data }, { me, models })
},
removeWallet: async (parent, { id }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
const wallet = await models.wallet.findUnique({ where: { userId: me.id, id: Number(id) } })
if (!wallet) {
throw new GraphQLError('wallet not found', { extensions: { code: 'BAD_INPUT' } })
}
await models.$transaction([
models.wallet.delete({ where: { userId: me.id, id: Number(id) } }),
models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet deleted' } })
])
return true
},
buyCredits: async (parent, { credits }, { me, models, lnd }) => {
return await performPaidAction('BUY_CREDITS', { credits }, { models, me, lnd })
deleteWalletLogs: async (parent, { wallet }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
await models.walletLog.deleteMany({ where: { userId: me.id, wallet } })
return true
}
},
Withdrawl: {
satsPaying: w => msatsToSats(w.msatsPaying),
satsPaid: w => msatsToSats(w.msatsPaid),
satsFeePaying: w => w.invoiceForward ? 0 : msatsToSats(w.msatsFeePaying),
satsFeePaid: w => w.invoiceForward ? 0 : msatsToSats(w.msatsFeePaid),
// we never want to fetch the sensitive data full monty in nested resolvers
forwardedActionType: async (withdrawl, args, { models }) => {
return (await models.invoiceForward.findUnique({
where: { withdrawlId: Number(withdrawl.id) },
include: {
invoice: true
}
}))?.invoice?.actionType
},
preimage: async (withdrawl, args, { lnd }) => {
try {
if (withdrawl.status === 'CONFIRMED' && withdrawl.hash) {
return withdrawl.preimage ?? (await getPayment({ id: withdrawl.hash, lnd })).payment.secret
}
} catch (err) {
console.error('error fetching payment from LND', err)
}
}
},
Direct: {
nostr: async (direct, args, { models }) => {
try {
return JSON.parse(direct.desc)
} catch (err) {
}
return null
},
sats: direct => msatsToSats(direct.msats)
satsFeePaying: w => msatsToSats(w.msatsFeePaying),
satsFeePaid: w => msatsToSats(w.msatsFeePaid)
},
Invoice: {
satsReceived: i => msatsToSats(i.msatsReceived),
satsRequested: i => msatsToSats(i.msatsRequested),
// we never want to fetch the sensitive data full monty in nested resolvers
forwardStatus: async (invoice, args, { models }) => {
const forward = await models.invoiceForward.findUnique({
where: { invoiceId: Number(invoice.id) },
include: {
withdrawl: true
}
})
return forward?.withdrawl?.status
},
forwardedSats: async (invoice, args, { models }) => {
const msats = (await models.invoiceForward.findUnique({
where: { invoiceId: Number(invoice.id) },
include: {
withdrawl: true
}
}))?.withdrawl?.msatsPaid
return msats ? msatsToSats(msats) : null
},
invoiceForward: async (invoice, args, { models }) => {
return !!invoice.invoiceForward || !!(await models.invoiceForward.findUnique({ where: { invoiceId: Number(invoice.id) } }))
},
nostr: async (invoice, args, { models }) => {
try {
return JSON.parse(invoice.desc)
} catch (err) {
}
return null
},
confirmedPreimage: async (invoice, args, { lnd }) => {
try {
if (invoice.confirmedAt) {
return invoice.preimage ?? (await getInvoiceFromLnd({ id: invoice.hash, lnd })).secret
}
} catch (err) {
console.error('error fetching invoice from LND', err)
}
return null
},
item: async (invoice, args, { models, me }) => {
if (!invoice.actionId) return null
switch (invoice.actionType) {
case 'ITEM_CREATE':
case 'ZAP':
case 'DOWN_ZAP':
case 'POLL_VOTE':
case 'BOOST':
return (await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
WHERE id = $1`
}, Number(invoice.actionId)))?.[0]
default:
return null
}
},
itemAct: async (invoice, args, { models, me }) => {
const action2act = {
ZAP: 'TIP',
DOWN_ZAP: 'DONT_LIKE_THIS',
POLL_VOTE: 'POLL',
BOOST: 'BOOST'
}
switch (invoice.actionType) {
case 'ZAP':
case 'DOWN_ZAP':
case 'POLL_VOTE':
case 'BOOST':
return (await models.$queryRaw`
SELECT id, act, "invoiceId", "invoiceActionState", msats
FROM "ItemAct"
WHERE "ItemAct"."invoiceId" = ${Number(invoice.id)}::INTEGER
AND "ItemAct"."userId" = ${me?.id}::INTEGER
AND act = ${action2act[invoice.actionType]}::"ItemActType"`
)?.[0]
default:
return null
}
}
satsRequested: i => msatsToSats(i.msatsRequested)
},
Fact: {
@ -525,84 +564,161 @@ const resolvers = {
}
}
export default resolvers
export const addWalletLog = async ({ wallet, level, message }, { me, models }) => {
try {
await models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level, message } })
} catch (err) {
console.error('error creating wallet log:', err)
}
}
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, protocol, logger }) {
async function upsertWallet (
{ schema, wallet, testConnect }, { settings, data }, { me, models }) {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
assertApiKeyNotPermitted({ me })
await validateSchema(withdrawlSchema, { invoice, maxFee })
await ssValidate(schema, { ...data, ...settings }, { me, models })
if (testConnect) {
try {
await testConnect(data)
} catch (err) {
console.error(err)
await addWalletLog({ wallet, level: 'ERROR', message: 'failed to attach wallet' }, { me, models })
throw new GraphQLError('failed to connect to wallet', { extensions: { code: 'BAD_INPUT' } })
}
}
const { id, ...walletData } = data
const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, priority } = settings
const txs = [
models.user.update({
where: { id: me.id },
data: {
autoWithdrawMaxFeePercent,
autoWithdrawThreshold
}
})
]
if (priority) {
txs.push(
models.wallet.updateMany({
where: {
userId: me.id
},
data: {
priority: 0
}
}))
}
if (id) {
txs.push(
models.wallet.update({
where: { id: Number(id), userId: me.id },
data: {
priority: priority ? 1 : 0,
[wallet.field]: {
update: {
where: { walletId: Number(id) },
data: walletData
}
}
}
}),
models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet updated' } })
)
} else {
txs.push(
models.wallet.create({
data: {
priority: Number(priority),
userId: me.id,
type: wallet.type,
[wallet.field]: {
create: walletData
}
}
}),
models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet created' } })
)
}
await models.$transaction(txs)
return true
}
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, walletId = null }) {
assertApiKeyNotPermitted({ me })
await ssValidate(withdrawlSchema, { invoice, maxFee })
await assertGofacYourself({ models, headers })
// remove 'lightning:' prefix if present
invoice = invoice.replace(/^lightning:/, '')
// decode invoice to get amount
let decoded, sockets
let decoded, node
try {
decoded = await parsePaymentRequest({ request: invoice })
decoded = await decodePaymentRequest({ lnd, request: invoice })
} catch (error) {
console.log(error)
throw new GqlInputError('could not decode invoice')
throw new GraphQLError('could not decode invoice', { extensions: { code: 'BAD_INPUT' } })
}
try {
sockets = await getNodeSockets({ lnd, public_key: decoded.destination })
node = await getNode({ lnd, public_key: decoded.destination, is_omitting_channels: true })
} catch (error) {
// likely not found if it's an unannounced channel, e.g. phoenix
console.log(error)
}
if (sockets) {
for (const { socket } of sockets) {
if (node) {
for (const { socket } of node.sockets) {
const ip = socket.split(':')[0]
await assertGofacYourself({ models, headers, ip })
}
}
if (!decoded.mtokens || BigInt(decoded.mtokens) <= 0) {
throw new GqlInputError('invoice must specify an amount')
throw new GraphQLError('your invoice must specify an amount', { extensions: { code: 'BAD_INPUT' } })
}
if (decoded.mtokens > Number.MAX_SAFE_INTEGER) {
throw new GqlInputError('invoice amount is too large')
}
const msatsFee = Number(maxFee) * 1000
// check if there's an invoice with same hash that has an invoiceForward
// we can't allow this because it creates two outgoing payments from our node
// with the same hash
const selfPayment = await models.invoice.findUnique({
where: { hash: decoded.id },
include: { invoiceForward: true }
})
if (selfPayment?.invoiceForward) {
throw new GqlInputError('SN cannot pay an invoice that SN is proxying')
}
const user = await models.user.findUnique({ where: { id: me.id } })
return await performPayingAction({ bolt11: invoice, maxFee, protocolId: protocol?.id }, { me, models, lnd })
const autoWithdraw = !!walletId
// create withdrawl transactionally (id, bolt11, amount, fee)
const [withdrawl] = await serialize(
models.$queryRaw`SELECT * FROM create_withdrawl(${decoded.id}, ${invoice},
${Number(decoded.mtokens)}, ${msatsFee}, ${user.name}, ${autoWithdraw}, ${walletId}::INTEGER)`,
{ models }
)
payViaPaymentRequest({
lnd,
request: invoice,
// can't use max_fee_mtokens https://github.com/alexbosworth/ln-service/issues/141
max_fee: Number(maxFee),
pathfinding_timeout: 30000
}).catch(console.error)
return withdrawl
}
async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
{ me, models, lnd, headers }) {
export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
{ me, models, lnd, headers, autoWithdraw = false }) {
if (!me) {
throw new GqlAuthenticationError()
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
assertApiKeyNotPermitted({ me })
const res = await fetchLnAddrInvoice({ addr, amount, maxFee, comment, ...payer },
{
me,
models,
lnd
})
// take pr and createWithdrawl
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers })
}
async function fetchLnAddrInvoice (
{ addr, amount, maxFee, comment, ...payer },
{ me, models, lnd }) {
const options = await lnAddrOptions(addr)
await validateSchema(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
if (payer) {
payer = {
@ -636,7 +752,15 @@ async function fetchLnAddrInvoice (
// decode invoice
try {
const decoded = await parsePaymentRequest({ request: res.pr })
const decoded = await decodePaymentRequest({ lnd, request: res.pr })
const ourPubkey = (await getIdentity({ lnd })).public_key
if (autoWithdraw && decoded.destination === ourPubkey && process.env.NODE_ENV === 'production') {
// unset lnaddr so we don't trigger another withdrawal with same destination
await models.wallet.deleteMany({
where: { userId: me.id, type: Wallet.LnAddr.type }
})
throw new Error('automated withdrawals to other stackers are not allowed')
}
if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) {
throw new Error('invoice has incorrect amount')
}
@ -645,5 +769,6 @@ async function fetchLnAddrInvoice (
throw e
}
return res
// take pr and createWithdrawl
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers, autoWithdraw })
}

17
api/slack/index.js Normal file
View File

@ -0,0 +1,17 @@
import { WebClient, LogLevel } from '@slack/web-api'
const slackClient = global.slackClient || (() => {
if (!process.env.SLACK_BOT_TOKEN && !process.env.SLACK_CHANNEL_ID) {
console.warn('SLACK_* env vars not set, skipping slack setup')
return null
}
console.log('initing slack client')
const client = new WebClient(process.env.SLACK_BOT_TOKEN, {
logLevel: LogLevel.INFO
})
return client
})()
if (process.env.NODE_ENV === 'development') global.slackClient = slackClient
export default slackClient

View File

@ -13,9 +13,6 @@ import { BLOCK_HEIGHT } from '@/fragments/blockHeight'
import { CHAIN_FEE } from '@/fragments/chainFee'
import { getServerSession } from 'next-auth/next'
import { getAuthOptions } from '@/pages/api/auth/[...nextauth]'
import { NOFOLLOW_LIMIT } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
import { MULTI_AUTH_ANON, MULTI_AUTH_POINTER } from '@/lib/auth'
export default async function getSSRApolloClient ({ req, res, me = null }) {
const session = req && await getServerSession(req, res, getAuthOptions(req))
@ -43,93 +40,20 @@ export default async function getSSRApolloClient ({ req, res, me = null }) {
watchQuery: {
fetchPolicy: 'no-cache',
nextFetchPolicy: 'no-cache',
canonizeResults: true,
ssr: true
},
query: {
fetchPolicy: 'no-cache',
nextFetchPolicy: 'no-cache',
canonizeResults: true,
ssr: true
}
}
})
await client.clearStore()
return client
}
function oneDayReferral (request, { me }) {
if (!me) return
const refHeader = request.headers['x-stacker-news-referrer']
if (!refHeader) return
const referrers = refHeader.split('; ').filter(Boolean)
for (const referrer of referrers) {
let prismaPromise, getData
if (referrer.startsWith('item-')) {
prismaPromise = models.item.findUnique({
where: {
id: parseInt(referrer.slice(5)),
msats: {
gt: satsToMsats(NOFOLLOW_LIMIT)
},
weightedVotes: {
gt: 0
}
}
})
getData = item => ({
referrerId: item.userId,
refereeId: parseInt(me.id),
type: item.parentId ? 'COMMENT' : 'POST',
typeId: String(item.id)
})
} else if (referrer.startsWith('profile-')) {
const name = referrer.slice(8)
// exclude all pages that are not user profiles
if (['api', 'auth', 'day', 'invites', 'invoices', 'referrals', 'rewards',
'satistics', 'settings', 'stackers', 'wallet', 'withdrawals', '404', '500',
'email', 'live', 'login', 'notifications', 'offline', 'search', 'share',
'signup', 'territory', 'recent', 'top', 'edit', 'post', 'rss', 'saloon',
'faq', 'story', 'privacy', 'copyright', 'tos', 'changes', 'guide', 'daily',
'anon', 'ad'].includes(name)) continue
prismaPromise = models.user.findUnique({ where: { name } })
getData = user => ({
referrerId: user.id,
refereeId: parseInt(me.id),
type: 'PROFILE',
typeId: String(user.id)
})
} else if (referrer.startsWith('territory-')) {
prismaPromise = models.sub.findUnique({ where: { name: referrer.slice(10) } })
getData = sub => ({
referrerId: sub.userId,
refereeId: parseInt(me.id),
type: 'TERRITORY',
typeId: sub.name
})
} else {
prismaPromise = models.user.findUnique({ where: { name: referrer } })
getData = user => ({
referrerId: user.id,
refereeId: parseInt(me.id),
type: 'REFERRAL',
typeId: String(user.id)
})
}
prismaPromise?.then(ref => {
if (ref && getData) {
const data = getData(ref)
// can't refer yourself
if (data.refereeId === data.referrerId) return
models.oneDayReferral.create({ data }).catch(console.error)
}
}).catch(console.error)
}
}
/**
* Takes a query and variables and returns a getServerSideProps function
*
@ -152,20 +76,10 @@ export function getGetServerSideProps (
const client = await getSSRApolloClient({ req, res })
let { data: { me } } = await client.query({ query: ME })
// required to redirect to /signup on page reload
// if we switched to anon and authentication is required
if (req.cookies[MULTI_AUTH_POINTER] === MULTI_AUTH_ANON) {
me = null
}
const { data: { me } } = await client.query({ query: ME })
if (authRequired && !me) {
let callback = process.env.NEXT_PUBLIC_URL + req.url
// On client-side routing, the callback is a NextJS URL
// so we need to remove the NextJS stuff.
// Example: /_next/data/development/territory.json
callback = callback.replace(/\/_next\/data\/\w+\//, '/').replace(/\.json$/, '')
const callback = process.env.NEXT_PUBLIC_URL + req.url
return {
redirect: {
destination: `/signup?callbackUrl=${encodeURIComponent(callback)}`
@ -197,7 +111,6 @@ export function getGetServerSideProps (
}
if (error || !data || (notFound && notFound(data, vars, me))) {
error && console.error(error)
res.writeHead(302, {
Location: '/404'
}).end()
@ -211,8 +124,6 @@ export function getGetServerSideProps (
}
}
oneDayReferral(req, { me })
return {
props: {
...props,

View File

@ -13,8 +13,6 @@ export default gql`
spenderGrowth(when: String, from: String, to: String): [TimeData!]!
stackingGrowth(when: String, from: String, to: String): [TimeData!]!
stackerGrowth(when: String, from: String, to: String): [TimeData!]!
itemGrowthSubs(when: String, from: String, to: String, sub: String): [TimeData!]!
revenueGrowthSubs(when: String, from: String, to: String, sub: String): [TimeData!]!
}
type TimeData {

16
api/typeDefs/image.js Normal file
View File

@ -0,0 +1,16 @@
import { gql } from 'graphql-tag'
export default gql`
type ImageFeesInfo {
totalFees: Int!
totalFeesMsats: Int!
imageFee: Int!
imageFeeMsats: Int!
nUnpaid: Int!
bytesUnpaid: Int!
bytes24h: Int!
}
extend type Query {
imageFeesInfo(s3Keys: [Int]!): ImageFeesInfo!
}
`

View File

@ -17,7 +17,7 @@ import price from './price'
import admin from './admin'
import blockHeight from './blockHeight'
import chainFee from './chainFee'
import paidAction from './paidAction'
import image from './image'
const common = gql`
type Query {
@ -38,4 +38,4 @@ const common = gql`
`
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction]
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, image]

View File

@ -7,7 +7,7 @@ export default gql`
}
extend type Mutation {
createInvite(id: String, gift: Int!, limit: Int!, description: String): Invite
createInvite(gift: Int!, limit: Int): Invite
revokeInvite(id: ID!): Invite
}
@ -20,6 +20,5 @@ export default gql`
user: User!
revoked: Boolean!
poor: Boolean!
description: String
}
`

View File

@ -8,17 +8,8 @@ export default gql`
dupes(url: String!): [Item!]
related(cursor: String, title: String, id: ID, minMatch: String, limit: Limit): Items
search(q: String, sub: String, cursor: String, what: String, sort: String, when: String, from: String, to: String): Items
auctionPosition(sub: String, id: ID, boost: Int): Int!
boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
auctionPosition(sub: String, id: ID, bid: Int!): Int!
itemRepetition(parentId: ID): Int!
newComments(rootId: ID, after: Date): Comments!
}
type BoostPositions {
home: Boolean!
sub: Boolean!
homeMaxBoost: Int!
subMaxBoost: Int!
}
type TitleUnshorted {
@ -29,47 +20,28 @@ export default gql`
type ItemActResult {
id: ID!
sats: Int!
path: String
path: String!
act: String!
}
type ItemAct {
id: ID!
act: String!
invoice: Invoice
}
extend type Mutation {
bookmarkItem(id: ID): Item
pinItem(id: ID): Item
subscribeItem(id: ID): Item
deleteItem(id: ID): Item
upsertLink(
id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput],
hash: String, hmac: String): ItemPaidAction!
upsertDiscussion(
id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput],
hash: String, hmac: String): ItemPaidAction!
upsertBounty(
id: ID, sub: String, title: String!, text: String, bounty: Int, boost: Int, forward: [ItemForwardInput],
hash: String, hmac: String): ItemPaidAction!
upsertJob(
id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, boost: Int, status: String, logo: Int): ItemPaidAction!
upsertPoll(
id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date,
randPollOptions: Boolean, hash: String, hmac: String): ItemPaidAction!
upsertLink(id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item!
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item!
upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int, hash: String, hmac: String, boost: Int, forward: [ItemForwardInput]): Item!
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
text: String!, url: String!, maxBid: Int!, status: String, logo: Int, hash: String, hmac: String): Item!
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String, pollExpiresAt: Date): Item!
updateNoteId(id: ID!, noteId: String!): Item!
upsertComment(id: ID, text: String!, parentId: ID, boost: Int, hash: String, hmac: String): ItemPaidAction!
act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction!
pollVote(id: ID!): PollVotePaidAction!
upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item!
act(id: ID!, sats: Int, act: String, idempotent: Boolean, hash: String, hmac: String): ItemActResult!
pollVote(id: ID!, hash: String, hmac: String): ID!
toggleOutlaw(id: ID!): Item!
}
type PollVoteResult {
id: ID!
}
type PollOption {
id: ID,
option: String!
@ -78,18 +50,14 @@ export default gql`
type Poll {
meVoted: Boolean!
meInvoiceId: Int
meInvoiceActionState: InvoiceActionState
count: Int!
options: [PollOption!]!
randPollOptions: Boolean
}
type Items {
cursor: String
items: [Item!]!
pins: [Item!]
ad: Item
}
type Comments {
@ -97,19 +65,10 @@ export default gql`
comments: [Item!]!
}
enum InvoiceActionState {
PENDING
PENDING_HELD
HELD
PAID
FAILED
}
type Item {
id: ID!
createdAt: Date!
updatedAt: Date!
invoicePaidAt: Date
deletedAt: Date
deleteScheduledAt: Date
reminderScheduledAt: Date
@ -130,13 +89,10 @@ export default gql`
bountyPaidTo: [Int]
noteId: String
sats: Int!
credits: Int!
commentSats: Int!
commentCredits: Int!
lastCommentAt: Date
upvotes: Int!
meSats: Int!
meCredits: Int!
meDontLikeSats: Int!
meBookmark: Boolean!
meSubscription: Boolean!
@ -147,12 +103,11 @@ export default gql`
bio: Boolean!
paidImgLink: Boolean
ncomments: Int!
nDirectComments: Int!
comments(sort: String, cursor: String): Comments!
injected: Boolean!
comments(sort: String): [Item!]!
path: String
position: Int
prior: Int
maxBid: Int
isJob: Boolean!
pollCost: Int
poll: Poll
@ -162,7 +117,7 @@ export default gql`
remote: Boolean
sub: Sub
subName: String
status: String!
status: String
uploadId: Int
otsHash: String
parentOtsHash: String
@ -170,8 +125,6 @@ export default gql`
imgproxyUrls: JSONObject
rel: String
apiKey: Boolean
invoice: Invoice
cost: Int!
}
input ItemForwardInput {

View File

@ -55,12 +55,6 @@ export default gql`
sortTime: Date!
}
type Invoicification {
id: ID!
invoice: Invoice!
sortTime: Date!
}
type JobChanged {
id: ID!
item: Item!
@ -75,6 +69,12 @@ export default gql`
tipComments: Int!
}
type Streak {
id: ID!
sortTime: Date!
days: Int
}
type Earn {
id: ID!
earnedSats: Int!
@ -83,19 +83,6 @@ export default gql`
sources: EarnSources
}
type ReferralSources {
id: ID!
forever: Int!
oneDay: Int!
}
type ReferralReward {
id: ID!
earnedSats: Int!
sortTime: Date!
sources: ReferralSources
}
type Revenue {
id: ID!
earnedSats: Int!
@ -117,12 +104,9 @@ export default gql`
withdrawl: Withdrawl!
}
union ReferralSource = Item | Sub | User
type Referral {
id: ID!
sortTime: Date!
source: ReferralSource
}
type SubStatus {
@ -149,37 +133,10 @@ export default gql`
sortTime: Date!
}
type CowboyHat {
id: ID!
sortTime: Date!
days: Int
}
type NewHorse {
id: ID!
sortTime: Date!
}
type LostHorse {
id: ID!
sortTime: Date!
}
type NewGun {
id: ID!
sortTime: Date!
}
type LostGun {
id: ID!
sortTime: Date!
}
union Notification = Reply | Votification | Mention
| Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
| FollowActivity | ForwardedVotification | Revenue | SubStatus
| TerritoryPost | TerritoryTransfer | Reminder | ItemMention | Invoicification
| ReferralReward | CowboyHat | NewHorse | LostHorse | NewGun | LostGun
| Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus
| TerritoryPost | TerritoryTransfer | Reminder | ItemMention
type Notifications {
lastChecked: Date

View File

@ -1,61 +0,0 @@
import { gql } from 'graphql-tag'
export default gql`
extend type Query {
paidAction(invoiceId: Int!): PaidAction
}
extend type Mutation {
retryPaidAction(invoiceId: Int!, newAttempt: Boolean): PaidAction!
}
enum PaymentMethod {
REWARD_SATS
FEE_CREDIT
ZERO_COST
OPTIMISTIC
PESSIMISTIC
}
interface PaidAction {
invoice: Invoice
paymentMethod: PaymentMethod!
}
type ItemPaidAction implements PaidAction {
result: Item
invoice: Invoice
paymentMethod: PaymentMethod!
}
type ItemActPaidAction implements PaidAction {
result: ItemActResult
invoice: Invoice
paymentMethod: PaymentMethod!
}
type PollVotePaidAction implements PaidAction {
result: PollVoteResult
invoice: Invoice
paymentMethod: PaymentMethod!
}
type SubPaidAction implements PaidAction {
result: Sub
invoice: Invoice
paymentMethod: PaymentMethod!
}
type DonatePaidAction implements PaidAction {
result: DonateResult
invoice: Invoice
paymentMethod: PaymentMethod!
}
type BuyCreditsPaidAction implements PaidAction {
result: BuyCreditsResult
invoice: Invoice
paymentMethod: PaymentMethod!
}
`

View File

@ -2,6 +2,12 @@ import { gql } from 'graphql-tag'
export default gql`
extend type Query {
referrals(when: String, from: String, to: String): [TimeData!]!
referrals(when: String, from: String, to: String): Referrals!
}
type Referrals {
totalSats: Int!
totalReferrals: Int!
stats: [TimeData!]!
}
`

View File

@ -7,18 +7,14 @@ export default gql`
}
extend type Mutation {
donateToRewards(sats: Int!): DonatePaidAction!
}
type DonateResult {
sats: Int!
donateToRewards(sats: Int!, hash: String, hmac: String): Int!
}
type Rewards {
total: Int!
time: Date!
sources: [NameValue!]!
ad: Item
leaderboard: UsersNullable
}
type Reward {

View File

@ -7,8 +7,6 @@ export default gql`
subs: [Sub!]!
topSubs(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
mySubscribedSubs(cursor: String): Subs
subSuggestions(q: String!, limit: Limit): [Sub!]!
}
type Subs {
@ -18,22 +16,21 @@ export default gql`
extend type Mutation {
upsertSub(oldName: String, name: String!, desc: String, baseCost: Int!,
replyCost: Int!,
postTypes: [String!]!,
postTypes: [String!]!, allowFreebies: Boolean!,
billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
paySub(name: String!): SubPaidAction!
moderated: Boolean!, hash: String, hmac: String, nsfw: Boolean!): Sub
paySub(name: String!, hash: String, hmac: String): Sub
toggleMuteSub(name: String!): Boolean!
toggleSubSubscription(name: String!): Boolean!
transferTerritory(subName: String!, userName: String!): Sub
unarchiveTerritory(name: String!, desc: String, baseCost: Int!,
replyCost: Int!, postTypes: [String!]!,
postTypes: [String!]!, allowFreebies: Boolean!,
billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
moderated: Boolean!, hash: String, hmac: String, nsfw: Boolean!): Sub
}
type Sub {
name: String!
name: ID!
createdAt: Date!
userId: Int!
user: User!
@ -48,7 +45,6 @@ export default gql`
billedLastAt: Date!
billPaidUntil: Date
baseCost: Int!
replyCost: Int!
status: String!
moderated: Boolean!
moderatedCount: Int!

View File

@ -1,26 +1,12 @@
import { gql } from 'graphql-tag'
export default gql`
type UploadFees {
totalFees: Int!
totalFeesMsats: Int!
uploadFees: Int!
uploadFeesMsats: Int!
nUnpaid: Int!
bytesUnpaid: Int!
bytes24h: Int!
extend type Mutation {
getSignedPOST(type: String!, size: Int!, width: Int!, height: Int!, avatar: Boolean): SignedPost!
}
type SignedPost {
url: String!
fields: JSONObject!
}
extend type Query {
uploadFees(s3Keys: [Int]!): UploadFees!
}
extend type Mutation {
getSignedPOST(type: String!, size: Int!, width: Int!, height: Int!, avatar: Boolean): SignedPost!
}
`

View File

@ -4,7 +4,7 @@ export default gql`
extend type Query {
me: User
settings: User
user(id: ID, name: String): User
user(name: String!): User
users: [User!]
nameAvailable(name: String!): Boolean!
topUsers(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): UsersNullable!
@ -29,40 +29,26 @@ export default gql`
users: [User!]!
}
input CropData {
x: Float!
y: Float!
width: Float!
height: Float!
originalWidth: Int!
originalHeight: Int!
scale: Float!
}
extend type Mutation {
setName(name: String!): String
setSettings(settings: SettingsInput!): User
cropPhoto(photoId: ID!, cropData: CropData): String!
setPhoto(photoId: ID!): Int!
upsertBio(text: String!): ItemPaidAction!
upsertBio(bio: String!): User!
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
unlinkAuth(authType: String!): AuthMethods!
linkUnverifiedEmail(email: String!): Boolean
hideWelcomeBanner: Boolean
hideWalletRecvPrompt: Boolean
subscribeUserPosts(id: ID): User
subscribeUserComments(id: ID): User
toggleMute(id: ID): User
generateApiKey(id: ID!): String
deleteApiKey(id: ID!): User
disableFreebies: Boolean
setDiagnostics(diagnostics: Boolean!): Boolean
}
type User {
id: ID!
createdAt: Date!
name: String!
name: String
nitems(when: String, from: String, to: String): Int!
nposts(when: String, from: String, to: String): Int!
nterritories(when: String, from: String, to: String): Int!
@ -72,11 +58,6 @@ export default gql`
photoId: Int
since: Int
"""
this is only returned when we sort stackers by value
"""
proportion: Float
optional: UserOptional!
privates: UserPrivates
@ -87,10 +68,10 @@ export default gql`
input SettingsInput {
autoDropBolt11s: Boolean!
diagnostics: Boolean!
noReferralLinks: Boolean!
fiatCurrency: String!
satsFilter: Int!
disableFreebies: Boolean
greeterMode: Boolean!
hideBookmarks: Boolean!
hideCowboyHat: Boolean!
hideGithub: Boolean!
@ -101,7 +82,6 @@ export default gql`
hideIsContributor: Boolean!
hideWalletBalance: Boolean!
imgproxyOnly: Boolean!
showImagesAndVideos: Boolean!
nostrCrossposting: Boolean!
nostrPubkey: String
nostrRelays: [String!]
@ -118,8 +98,6 @@ export default gql`
noteItemMentions: Boolean!
nsfwMode: Boolean!
tipDefault: Int!
tipRandomMin: Int
tipRandomMax: Int
turboTipping: Boolean!
zapUndos: Int
wildWestMode: Boolean!
@ -140,7 +118,6 @@ export default gql`
extremely sensitive
"""
sats: Int!
credits: Int!
authMethods: AuthMethods!
lnAddr: String
@ -149,22 +126,18 @@ export default gql`
"""
lastCheckedJobs: String
hideWelcomeBanner: Boolean!
hideWalletRecvPrompt: Boolean!
tipPopover: Boolean!
upvotePopover: Boolean!
hasInvites: Boolean!
apiKeyEnabled: Boolean!
showPassphrase: Boolean!
diagnostics: Boolean!
"""
mirrors SettingsInput
"""
autoDropBolt11s: Boolean!
diagnostics: Boolean!
noReferralLinks: Boolean!
fiatCurrency: String!
satsFilter: Int!
disableFreebies: Boolean
greeterMode: Boolean!
hideBookmarks: Boolean!
hideCowboyHat: Boolean!
@ -176,7 +149,6 @@ export default gql`
hideIsContributor: Boolean!
hideWalletBalance: Boolean!
imgproxyOnly: Boolean!
showImagesAndVideos: Boolean!
nostrCrossposting: Boolean!
nostrPubkey: String
nostrRelays: [String!]
@ -193,17 +165,12 @@ export default gql`
noteItemMentions: Boolean!
nsfwMode: Boolean!
tipDefault: Int!
tipRandom: Boolean!
tipRandomMin: Int
tipRandomMax: Int
turboTipping: Boolean!
zapUndos: Int
wildWestMode: Boolean!
withdrawMaxFeeDefault: Int!
autoWithdrawThreshold: Int
vaultKeyHash: String
vaultKeyHashUpdatedAt: Date
walletsUpdatedAt: Date
autoWithdrawMaxFeePercent: Float
}
type UserOptional {
@ -214,18 +181,13 @@ export default gql`
spent(when: String, from: String, to: String): Int
referrals(when: String, from: String, to: String): Int
streak: Int
gunStreak: Int
horseStreak: Int
hasSendWallet: Boolean
hasRecvWallet: Boolean
hideWalletRecvPrompt: Boolean
maxStreak: Int
isContributor: Boolean
githubId: String
twitterId: String
nostrAuthPubkey: String
}
type NameValue {
name: String!
value: Float!

View File

@ -1,351 +1,70 @@
import { gql } from 'graphql-tag'
const shared = 'walletId: ID, templateName: ID, enabled: Boolean!'
const typeDefs = gql`
export default gql`
extend type Query {
invoice(id: ID!): Invoice!
withdrawl(id: ID!): Withdrawl!
direct(id: ID!): Direct!
numBolt11s: Int!
connectAddress: String!
walletHistory(cursor: String, inc: String): History
wallets: [WalletOrTemplate!]!
wallet(id: ID, name: String): WalletOrTemplate
walletSettings: WalletSettings!
walletLogs(protocolId: Int, cursor: String, debug: Boolean): WalletLogs!
failedInvoices: [Invoice!]!
wallets: [Wallet!]!
wallet(id: ID!): Wallet
walletByType(type: String!): Wallet
walletLogs: [WalletLog]!
}
extend type Mutation {
createInvoice(amount: Int!, expireSecs: Int, hodlInvoice: Boolean): Invoice!
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl!
cancelInvoice(hash: String!, hmac: String, userCancel: Boolean): Invoice!
dropBolt11(hash: String!): Boolean
buyCredits(credits: Int!): BuyCreditsPaidAction!
# upserts
upsertWalletSendLNbits(
${shared},
url: String!,
apiKey: VaultEntryInput!
): WalletSendLNbits!
upsertWalletRecvLNbits(
${shared},
url: String!,
apiKey: String!
): WalletRecvLNbits!
upsertWalletSendPhoenixd(
${shared},
url: String!,
apiKey: VaultEntryInput!
): WalletSendPhoenixd!
upsertWalletRecvPhoenixd(
${shared},
url: String!,
apiKey: String!
): WalletRecvPhoenixd!
upsertWalletSendBlink(
${shared},
currency: VaultEntryInput!,
apiKey: VaultEntryInput!
): WalletSendBlink!
upsertWalletRecvBlink(
${shared},
currency: String!,
apiKey: String!
): WalletRecvBlink!
upsertWalletRecvLightningAddress(
${shared},
address: String!
): WalletRecvLightningAddress!
upsertWalletSendNWC(
${shared},
url: VaultEntryInput!
): WalletSendNWC!
upsertWalletRecvNWC(
${shared},
url: String!
): WalletRecvNWC!
upsertWalletRecvCLNRest(
${shared},
socket: String!,
rune: String!,
cert: String
): WalletRecvCLNRest!
upsertWalletRecvLNDGRPC(
${shared},
socket: String!,
macaroon: String!,
cert: String
): WalletRecvLNDGRPC!
upsertWalletSendLNC(
${shared},
pairingPhrase: VaultEntryInput!,
localKey: VaultEntryInput!,
remoteKey: VaultEntryInput!,
serverHost: VaultEntryInput!
): WalletSendLNC!
upsertWalletSendWebLN(
${shared}
): WalletSendWebLN!
# tests
testWalletRecvNWC(
url: String!
): Boolean!
testWalletRecvLightningAddress(
address: String!
): Boolean!
testWalletRecvCLNRest(
socket: String!,
rune: String!,
cert: String
): Boolean!
testWalletRecvLNDGRPC(
socket: String!,
macaroon: String!,
cert: String
): Boolean!
testWalletRecvPhoenixd(
url: String!
apiKey: String!
): Boolean!
testWalletRecvLNbits(
url: String!
apiKey: String!
): Boolean!
testWalletRecvBlink(
currency: String!
apiKey: String!
): Boolean!
# delete
cancelInvoice(hash: String!, hmac: String!): Invoice!
dropBolt11(id: ID): Withdrawl
upsertWalletLND(id: ID, socket: String!, macaroon: String!, cert: String, settings: AutowithdrawSettings!): Boolean
upsertWalletCLN(id: ID, socket: String!, rune: String!, cert: String, settings: AutowithdrawSettings!): Boolean
upsertWalletLNAddr(id: ID, address: String!, settings: AutowithdrawSettings!): Boolean
removeWallet(id: ID!): Boolean
removeWalletProtocol(id: ID!): Boolean
# crypto
updateWalletEncryption(keyHash: String!, wallets: [WalletEncryptionUpdate!]!): Boolean
updateKeyHash(keyHash: String!): Boolean
resetWallets(newKeyHash: String!): Boolean
disablePassphraseExport: Boolean
# settings
setWalletSettings(settings: WalletSettingsInput!): Boolean
setWalletPriorities(priorities: [WalletPriorityUpdate!]!): Boolean
# logs
addWalletLog(protocolId: Int, level: String!, message: String!, timestamp: Date!, invoiceId: Int): Boolean
deleteWalletLogs(protocolId: Int, debug: Boolean): Boolean
}
type BuyCreditsResult {
credits: Int!
}
interface InvoiceOrDirect {
id: ID!
}
union WalletOrTemplate = Wallet | WalletTemplate
enum WalletStatus {
OK
WARNING
ERROR
DISABLED
deleteWalletLogs(wallet: String): Boolean
}
type Wallet {
id: ID!
name: String!
priority: Int!
template: WalletTemplate!
protocols: [WalletProtocol!]!
send: WalletStatus!
receive: WalletStatus!
createdAt: Date!
type: String!
priority: Boolean!
wallet: WalletDetails!
}
type WalletTemplate {
name: ID!
protocols: [WalletProtocolTemplate!]!
send: WalletStatus!
receive: WalletStatus!
}
type WalletProtocol {
id: ID!
name: String!
send: Boolean!
enabled: Boolean!
config: WalletProtocolConfig!
status: WalletStatus!
}
type WalletProtocolTemplate {
id: ID!
name: String!
send: Boolean!
}
union WalletProtocolConfig =
| WalletSendNWC
| WalletSendLNbits
| WalletSendPhoenixd
| WalletSendBlink
| WalletSendWebLN
| WalletSendLNC
| WalletRecvNWC
| WalletRecvLNbits
| WalletRecvPhoenixd
| WalletRecvBlink
| WalletRecvLightningAddress
| WalletRecvCLNRest
| WalletRecvLNDGRPC
type WalletSettings {
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
autoWithdrawThreshold: Int
autoWithdrawMaxFeePercent: Float
autoWithdrawMaxFeeTotal: Int
proxyReceive: Boolean!
}
input WalletSettingsInput {
receiveCreditsBelowSats: Int!
sendCreditsBelowSats: Int!
autoWithdrawThreshold: Int!
autoWithdrawMaxFeePercent: Float!
autoWithdrawMaxFeeTotal: Int!
proxyReceive: Boolean!
}
type WalletSendNWC {
id: ID!
url: VaultEntry!
}
type WalletSendLNbits {
id: ID!
url: String!
apiKey: VaultEntry!
}
type WalletSendPhoenixd {
id: ID!
url: String!
apiKey: VaultEntry!
}
type WalletSendBlink {
id: ID!
currency: VaultEntry!
apiKey: VaultEntry!
}
type WalletSendWebLN {
id: ID!
}
type WalletSendLNC {
id: ID!
pairingPhrase: VaultEntry!
localKey: VaultEntry!
remoteKey: VaultEntry!
serverHost: VaultEntry!
}
type WalletRecvNWC {
id: ID!
url: String!
}
type WalletRecvLNbits {
id: ID!
url: String!
apiKey: String!
}
type WalletRecvPhoenixd {
id: ID!
url: String!
apiKey: String!
}
type WalletRecvBlink {
id: ID!
currency: String!
apiKey: String!
}
type WalletRecvLightningAddress {
id: ID!
type WalletLNAddr {
address: String!
}
type WalletRecvCLNRest {
id: ID!
socket: String!
rune: String!
cert: String
}
type WalletRecvLNDGRPC {
id: ID!
type WalletLND {
socket: String!
macaroon: String!
cert: String
}
type WalletCLN {
socket: String!
rune: String!
cert: String
}
union WalletDetails = WalletLNAddr | WalletLND | WalletCLN
input AutowithdrawSettings {
autoWithdrawThreshold: Int!
autoWithdrawMaxFeePercent: Float!
autoWithdrawMaxFeeTotal: Int!
priority: Boolean!
}
input WalletEncryptionUpdate {
id: ID!
protocols: [WalletEncryptionUpdateProtocol!]!
}
input WalletEncryptionUpdateProtocol {
name: String!
send: Boolean!
config: JSONObject!
}
input WalletPriorityUpdate {
id: ID!
priority: Int!
}
type Invoice implements InvoiceOrDirect {
type Invoice {
id: ID!
createdAt: Date!
hash: String!
bolt11: String!
expiresAt: Date!
cancelled: Boolean!
cancelledAt: Date
confirmedAt: Date
satsReceived: Int
satsRequested: Int!
@ -355,14 +74,6 @@ const typeDefs = gql`
hmac: String
isHeld: Boolean
confirmedPreimage: String
actionState: String
actionType: String
actionError: String
invoiceForward: Boolean
item: Item
itemAct: ItemAct
forwardedSats: Int
forwardStatus: String
}
type Withdrawl {
@ -377,19 +88,6 @@ const typeDefs = gql`
status: String
autoWithdraw: Boolean!
preimage: String
forwardedActionType: String
}
type Direct implements InvoiceOrDirect {
id: ID!
createdAt: Date!
bolt11: String
hash: String
sats: Int
preimage: String
nostr: JSONObject
comment: String
lud18Data: JSONObject
}
type Fact {
@ -412,33 +110,11 @@ const typeDefs = gql`
cursor: String
}
type WalletLogs {
entries: [WalletLogEntry!]!
cursor: String
}
type WalletLogEntry {
type WalletLog {
id: ID!
createdAt: Date!
wallet: Wallet
protocol: WalletProtocol
wallet: ID!
level: String!
message: String!
context: JSONObject
}
type VaultEntry {
id: ID!
iv: String!
value: String!
createdAt: Date!
updatedAt: Date!
}
input VaultEntryInput {
iv: String!
value: String!
keyHash: String!
}
`
export default typeDefs

View File

@ -18,7 +18,7 @@ felipebueno,pr,#948,,,,,,100k,felipe@stacker.news,2024-03-26
benalleng,pr,#972,#923,good-first-issue,,,,20k,BenAllenG@stacker.news,2024-03-26
SatsAllDay,issue,#972,#923,good-first-issue,,,,2k,weareallsatoshi@getalby.com,2024-03-26
felipebueno,pr,#974,#884,good-first-issue,,,,20k,felipe@stacker.news,2024-03-26
h0dlr,issue,#974,#884,good-first-issue,,,,2k,HODLR@stacker.news,2024-04-04
h0dlr,issue,#974,#884,good-first-issue,,,,2k,0xe14b9b5981c729a3@ln.tips,2024-04-04
benalleng,pr,#975,,,,,,20k,BenAllenG@stacker.news,2024-03-26
SatsAllDay,security,#980,GHSA-qg4g-m4xq-695p,,,,,100k,weareallsatoshi@getalby.com,2024-03-28
SatsAllDay,code review,#980,GHSA-qg4g-m4xq-695p,medium,,,,25k,weareallsatoshi@getalby.com,2024-03-28
@ -110,151 +110,3 @@ SatsAllDay,pr,#1197,#1192,medium,,,,250k,weareallsatoshi@getalby.com,2024-06-03
tsmith123,pr,#1216,#1213,easy,,1,,90k,stickymarch60@walletofsatoshi.com,2024-06-03
tsmith123,pr,#1231,#1230,good-first-issue,,,,20k,stickymarch60@walletofsatoshi.com,2024-06-13
felipebueno,issue,#1231,#1230,good-first-issue,,,,2k,felipebueno@getalby.com,2024-06-13
tsmith123,pr,#1223,#107,medium,,2,10k bonus for our slowness,210k,stickymarch60@walletofsatoshi.com,2024-06-22
cointastical,issue,#1223,#107,medium,,2,,20k,cointastical@stacker.news,2024-06-22
kravhen,pr,#1215,#253,medium,,2,upgraded to medium,200k,nichro@getalby.com,2024-06-28
dillon-co,pr,#1140,#633,hard,,,requested advance,500k,bolt11,2024-07-02
takitakitanana,issue,,#1257,good-first-issue,,,,2k,takitakitanana@stacker.news,2024-07-11
SatsAllDay,pr,#1263,#1112,medium,,,1,225k,weareallsatoshi@getalby.com,2024-07-31
OneOneSeven117,issue,#1272,#1268,easy,,,,10k,OneOneSeven@stacker.news,2024-07-31
aniskhalfallah,pr,#1264,#1226,good-first-issue,,,,20k,aniskhalfallah@stacker.news,2024-07-31
Gudnessuche,issue,#1264,#1226,good-first-issue,,,,2k,everythingsatoshi@getalby.com,2024-08-10
aniskhalfallah,pr,#1289,,easy,,,,100k,aniskhalfallah@blink.sv,2024-08-12
riccardobl,pr,#1293,#1142,medium,high,,,500k,rblb@getalby.com,2024-08-18
tsmith123,pr,#1306,#832,medium,,,,250k,stickymarch60@walletofsatoshi.com,2024-08-20
riccardobl,pr,#1311,#864,medium,high,,pending unrelated refactor,500k,rblb@getalby.com,2024-08-27
brugeman,issue,#1311,#864,medium,high,,,50k,brugeman@stacker.news,2024-08-27
riccardobl,pr,#1342,#1141,hard,high,,pending unrelated rearchitecture,1m,rblb@getalby.com,2024-09-09
SatsAllDay,issue,#1368,#1331,medium,,,,25k,weareallsatoshi@getalby.com,2024-09-16
benalleng,helpfulness,#1368,#1170,medium,,,did a lot of it in #1175,25k,BenAllenG@stacker.news,2024-09-16
humble-GOAT,issue,#1412,#1407,good-first-issue,,,,2k,humble_GOAT@stacker.news,2024-09-18
felipebueno,issue,#1425,#986,medium,,,,25k,felipebueno@getalby.com,2024-09-26
riccardobl,pr,#1373,#1304,hard,high,,,2m,bolt11,2024-10-01
tsmith123,pr,#1428,#1397,easy,,1,superceded,90k,stickymarch60@walletofsatoshi.com,2024-10-02
toyota-corolla0,pr,#1449,,good-first-issue,,,,20k,toyota_corolla0@stacker.news,2024-10-02
toyota-corolla0,pr,#1455,#1437,good-first-issue,,,,20k,toyota_corolla0@stacker.news,2024-10-02
SouthKoreaLN,issue,#1436,,easy,,,,10k,south_korea_ln@stacker.news,2024-10-02
TonyGiorgio,issue,#1462,,easy,urgent,,,30k,TonyGiorgio@stacker.news,2024-10-07
hkarani,issue,#1369,#1458,good-first-issue,,,,2k,asterisk32@stacker.news,2024-10-21
toyota-corolla0,pr,#1369,#1458,good-first-issue,,,,20k,toyota_corolla0@stacker.news,2024-10-20
Soxasora,pr,#1593,#1569,good-first-issue,,,,20k,soxasora@blink.sv,2024-11-19
Soxasora,pr,#1599,#1258,medium,,,,250k,soxasora@blink.sv,2024-11-19
aegroto,pr,#1585,#1522,easy,high,,1,180k,aegroto@blink.sv,2024-11-19
sig47,issue,#1585,#1522,easy,high,,1,18k,siggy47@stacker.news,2024-11-19
aegroto,pr,#1583,#1572,easy,,,2,80k,aegroto@blink.sv,2024-11-19
Soxasora,pr,#1617,#1616,easy,,,,100k,soxasora@blink.sv,2024-11-20
Soxasora,issue,#1617,#1616,easy,,,,10k,soxasora@blink.sv,2024-11-20
AndreaDiazCorreia,helpfulness,#1605,#1566,good-first-issue,,,tried in pr,2k,andrea@lawallet.ar,2024-11-20
Soxasora,pr,#1653,,medium,,,determined unecessary,250k,soxasora@blink.sv,2024-12-07
Soxasora,pr,#1659,#1657,easy,,,,100k,soxasora@blink.sv,2024-12-07
sig47,issue,#1659,#1657,easy,,,,10k,siggy47@stacker.news,2024-12-07
Gudnessuche,issue,#1662,#1661,good-first-issue,,,,2k,everythingsatoshi@getalby.com,2024-12-07
aegroto,pr,#1589,#1586,easy,,,,100k,aegroto@blink.sv,2024-12-07
aegroto,issue,#1589,#1586,easy,,,,10k,aegroto@blink.sv,2024-12-07
aegroto,pr,#1619,#914,easy,,,,100k,aegroto@blink.sv,2024-12-07
felipebueno,pr,#1620,,medium,,,1,225k,felipebueno@getalby.com,2024-12-09
Soxasora,pr,#1647,#1645,easy,,,,100k,soxasora@blink.sv,2024-12-07
Soxasora,pr,#1667,#1568,easy,,,,100k,soxasora@blink.sv,2024-12-07
aegroto,pr,#1633,#1471,easy,,,1,90k,aegroto@blink.sv,2024-12-07
Darth-Coin,issue,#1649,#1421,medium,,,,25k,darthcoin@stacker.news,2024-12-07
Soxasora,pr,#1685,,medium,,,,250k,soxasora@blink.sv,2024-12-07
aegroto,pr,#1606,#1242,medium,,,,250k,aegroto@blink.sv,2024-12-07
sfr0xyz,issue,#1696,#1196,good-first-issue,,,,2k,sefiro@getalby.com,2024-12-10
Soxasora,pr,#1794,#756,hard,urgent,,includes #411,3m,bolt11,2025-01-09
Soxasora,pr,#1786,#363,easy,,,,100k,bolt11,2025-01-09
Soxasora,pr,#1768,#1186,medium-hard,,,,500k,bolt11,2025-01-09
Soxasora,pr,#1750,#1035,medium,,,,250k,bolt11,2025-01-09
SatsAllDay,issue,#1794,#411,hard,high,,,200k,weareallsatoshi@getalby.com,2025-01-20
felipebueno,issue,#1786,#363,easy,,,,10k,felipebueno@blink.sv,2025-01-27
cyphercosmo,pr,#1745,#1648,good-first-issue,,,2,16k,cyphercosmo@getalby.com,2025-01-27
Radentor,issue,#1768,#1186,medium-hard,,,,50k,revisedbird84@walletofsatoshi.com,2025-01-27
Soxasora,pr,#1841,#1692,good-first-issue,,,,20k,soxasora@blink.sv,2025-01-27
Soxasora,pr,#1839,#1790,easy,,,1,90k,soxasora@blink.sv,2025-01-27
Soxasora,pr,#1820,#1819,easy,,,1,90k,soxasora@blink.sv,2025-01-27
SatsAllDay,issue,#1820,#1819,easy,,,1,9k,weareallsatoshi@getalby.com,2025-01-27
Soxasora,pr,#1814,#1736,easy,,,,100k,soxasora@blink.sv,2025-01-27
jason-me,pr,#1857,,easy,,,,100k,rrbtc@vlt.ge,2025-02-08
ed-kung,pr,#1901,#323,good-first-issue,,,,20k,simplestacker@getalby.com,2025-02-14
Scroogey-SN,pr,#1911,#1905,good-first-issue,,,1,18k,Scroogey@coinos.io,2025-03-10
Scroogey-SN,pr,#1928,#1924,good-first-issue,,,,20k,Scroogey@coinos.io,2025-03-10
dtonon,issue,#1928,#1924,good-first-issue,,,,2k,tips@dtonon.com,2025-04-16
ed-kung,pr,#1926,#1914,medium-hard,,,,500k,simplestacker@getalby.com,2025-03-10
ed-kung,issue,#1926,#1914,medium-hard,,,,50k,simplestacker@getalby.com,2025-03-10
ed-kung,pr,#1926,#1927,easy,,,,100k,simplestacker@getalby.com,2025-03-10
ed-kung,issue,#1926,#1927,easy,,,,10k,simplestacker@getalby.com,2025-03-10
ed-kung,issue,#1913,#1890,good-first-issue,,,,2k,simplestacker@getalby.com,2025-03-10
Scroogey-SN,pr,#1930,#1167,good-first-issue,,,,20k,Scroogey@coinos.io,2025-03-10
itsrealfake,issue,#1930,#1167,good-first-issue,,,,2k,smallimagination100035@getalby.com,2025-04-02
Scroogey-SN,pr,#1948,#1849,medium,urgent,,,750k,Scroogey@coinos.io,2025-03-10
felipebueno,issue,#1947,#1945,good-first-issue,,,,2k,felipebueno@blink.sv,2025-03-10
ed-kung,pr,#1952,#1951,easy,,,,100k,simplestacker@getalby.com,2025-03-10
ed-kung,issue,#1952,#1951,easy,,,,10k,simplestacker@getalby.com,2025-03-10
Scroogey-SN,pr,#1973,#1959,good-first-issue,,,,20k,Scroogey@coinos.io,2025-04-02
benthecarman,issue,#1953,#1950,good-first-issue,,,,2k,me@benthecarman.com,2025-04-16
ed-kung,pr,#2012,#2004,easy,,,,100k,simplestacker@getalby.com,2025-04-02
ed-kung,issue,#2012,#2004,easy,,,,10k,simplestacker@getalby.com,2025-04-02
ed-kung,pr,#1993,#1982,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
rideandslide,issue,#1993,#1982,good-first-issue,,,,2k,koiora@getalby.com,2025-04-02
ed-kung,pr,#1972,#1254,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
SatsAllDay,issue,#1972,#1254,good-first-issue,,,,2k,weareallsatoshi@getalby.com,2025-04-02
ed-kung,pr,#1962,#1343,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
ed-kung,pr,#1962,#1217,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-02
ed-kung,pr,#1962,#866,easy,,,,100k,simplestacker@getalby.com,2025-04-02
felipebueno,issue,#1962,#866,easy,,,,10k,felipebueno@blink.sv,2025-04-02
cointastical,issue,#1962,#1217,good-first-issue,,,,2k,Cointastical@getAlby.com,2025-04-16
Scroogey-SN,pr,#1975,#1964,good-first-issue,,,,20k,Scroogey@coinos.io,2025-04-02
rideandslide,issue,#1986,#1985,good-first-issue,,,,2k,koiora@getalby.com,2025-04-02
kristapsk,issue,#1976,#841,good-first-issue,,,,2k,kristapsk@stacker.news,2025-04-16
ed-kung,pr,#2070,#2061,good-first-issue,,,,20k,simplestacker@getalby.com,2025-04-16
ed-kung,issue,#2070,#2061,good-first-issue,,,,2k,simplestacker@getalby.com,2025-04-16
ed-kung,pr,#2070,#2058,easy,,,,100k,simplestacker@getalby.com,2025-04-16
ed-kung,pr,#2070,#2047,medium-hard,,,,500k,simplestacker@getalby.com,2025-04-16
SouthKoreaLN,pr,#2068,#2064,good-first-issue,,,,20k,south_korea_ln@stacker.news,2025-04-16
kepford,issue,#2068,#2064,good-first-issue,,,,2k,penalwink141@minibits.cash,2025-04-16
SouthKoreaLN,pr,#2069,#1990,good-first-issue,,,,20k,south_korea_ln@stacker.news,2025-04-16
cointastical,issue,#2071,#1475,good-first-issue,,,,2k,Cointastical@getAlby.com,2025-04-16
brymut,pr,#2082,#2051,easy,low,3,,35k,brymut@stacker.news,2025-04-16
abhiShandy,pr,#2083,#1270,good-first-issue,,,,20k,abhishandy@stacker.news,2025-04-16
cointastical,issue,#2083,#1270,good-first-issue,,,,2k,Cointastical@getAlby.com,2025-04-16
brymut,pr,#2093,#1991,easy,,,,100k,brymut@stacker.news,2025-04-16
brymut,pr,#2100,#2090,easy,,3,,70k,brymut@stacker.news,2025-04-24
abhiShandy,pr,#2109,#1221,good-first-issue,,,,20k,abhishandy@stacker.news,2025-04-24
Gudnessuche,issue,#2109,#1221,good-first-issue,,,,2k,everythingsatoshi@getalby.com,2025-04-24
brymut,pr,#2153,#2087,easy,,,,100k,brymut@stacker.news,2025-05-13
brymut,pr,#2152,#2142,good-first-issue,,1,,18k,brymut@stacker.news,2025-05-13
m0wer,pr,#2124,#992,medium,,1,,225k,klk@stacker.news,2025-05-13
ed-kung,issue,#2072,#2043,easy,,,,10k,simplestacker@getalby.com,2025-05-15
ed-kung,helpfulness,#2072,#2043,easy,,,,10k,simplestacker@getalby.com,2025-05-15
SouthKoreaLN,pr,#2072,#2043,easy,,,,100k,south_korea_ln@stacker.news,2025-05-13
m0wer,pr,#2135,#1391,easy,,1,more difficult than planned,150k,klk@stacker.news,2025-05-13
sutt,pr,#2162,#2161,good-first-issue,,,,20k,bounty_hunter@stacker.news,2025-05-15
sutt,issue,#2162,#2161,good-first-issue,,,,2k,bounty_hunter@stacker.news,2025-05-15
brymut,pr,#2171,#2164,easy,,,,100k,brymut@stacker.news,2025-05-21
SouthKoreaLN,issue,#2171,#2164,easy,,,,10k,south_korea_ln@stacker.news,2025-05-21
brymut,pr,#2175,#2173,good-first-issue,,,,20k,brymut@stacker.news,2025-05-21
sutt,pr,#2185,#2183,easy,high,,,200k,bounty_hunter@stacker.news,2025-06-19
sutt,issue,#2185,#2183,easy,high,,,20k,bounty_hunter@stacker.news,2025-06-06
axelvyrn,advisory,#2205,GHSA-x2xp-x867-4jfc,,,,,100k,holonite@speed.app,2025-06-06
brymut,pr,#2184,#2165,easy,,,,100k,brymut@stacker.news,2025-06-06
sutt,pr,#2190,#2187,easy,,,,100k,bounty_hunter@stacker.news,2025-06-06
sutt,issue,#2190,#2187,easy,,,,10k,bounty_hunter@stacker.news,2025-06-06
sutt,pr,#2192,#2188,medium,,,,250k,bounty_hunter@stacker.news,2025-06-19
sutt,issue,#2192,#2188,medium,,,,25k,bounty_hunter@stacker.news,2025-06-19
abhiShandy,pr,#2195,#2181,good-first-issue,,1,,18k,abhishandy@stacker.news,2025-06-13
brymut,pr,#2191,#1409,medium,,2,,200k,brymut@stacker.news,2025-06-13
SatsAllDay,issue,#2191,#1409,medium,,,,20k,weareallsatoshi@getalby.com,2025-06-13
ed-kung,pr,#2217,#2039,medium,,,,250k,simplestacker@getalby.com,2025-06-18
ed-kung,issue,#2217,#2039,medium,,,,25k,simplestacker@getalby.com,2025-06-18
axelvyrn,pr,#2220,#2198,good-first-issue,,5,,10k,holonite@speed.app,2025-06-18
axelvyrn,issue,#2220,#2198,good-first-issue,,,,1k,holonite@speed.app,2025-06-18
brymut,pr,#2221,#2204,good-first-issue,,,,20k,brymut@stacker.news,2025-06-18
brymut,pr,#2235,#2233,good-first-issue,,,,20k,brymut@stacker.news,2025-06-18
brymut,pr,#2250,#2106,good-first-issue,,,,20k,brymut@stacker.news,2025-07-12
SouthKoreaLN,issue,#2267,#2164,easy,,,,10k,south_korea_ln@stacker.news,2025-07-12
pory-gone,pr,#2316,#2277,good-first-issue,,,,20k,pory@porygone.xyz,2025-08-01
brymut,pr,#2326,,good-first-issue,,,,20k,brymut@stacker.news,2025-07-31
brymut,pr,#2332,#2276,easy,,,,100k,brymut@stacker.news,2025-07-31
ed-kung,pr,#2373,#2371,good-first-issue,,,,20k,simplestacker@getalby.com,2025-07-31
ed-kung,issue,#2373,#2371,good-first-issue,,,,2k,simplestacker@getalby.com,2025-07-31
pory-gone,pr,#2381,#2370,good-first-issue,,,,20k,pory@porygone.xyz,???
pory-gone,pr,#2413,#2361,easy,,,,100k,pory@porygone.xyz,???

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
18 benalleng pr #972 #923 good-first-issue 20k BenAllenG@stacker.news 2024-03-26
19 SatsAllDay issue #972 #923 good-first-issue 2k weareallsatoshi@getalby.com 2024-03-26
20 felipebueno pr #974 #884 good-first-issue 20k felipe@stacker.news 2024-03-26
21 h0dlr issue #974 #884 good-first-issue 2k HODLR@stacker.news 0xe14b9b5981c729a3@ln.tips 2024-04-04
22 benalleng pr #975 20k BenAllenG@stacker.news 2024-03-26
23 SatsAllDay security #980 GHSA-qg4g-m4xq-695p 100k weareallsatoshi@getalby.com 2024-03-28
24 SatsAllDay code review #980 GHSA-qg4g-m4xq-695p medium 25k weareallsatoshi@getalby.com 2024-03-28
110 tsmith123 pr #1216 #1213 easy 1 90k stickymarch60@walletofsatoshi.com 2024-06-03
111 tsmith123 pr #1231 #1230 good-first-issue 20k stickymarch60@walletofsatoshi.com 2024-06-13
112 felipebueno issue #1231 #1230 good-first-issue 2k felipebueno@getalby.com 2024-06-13
tsmith123 pr #1223 #107 medium 2 10k bonus for our slowness 210k stickymarch60@walletofsatoshi.com 2024-06-22
cointastical issue #1223 #107 medium 2 20k cointastical@stacker.news 2024-06-22
kravhen pr #1215 #253 medium 2 upgraded to medium 200k nichro@getalby.com 2024-06-28
dillon-co pr #1140 #633 hard requested advance 500k bolt11 2024-07-02
takitakitanana issue #1257 good-first-issue 2k takitakitanana@stacker.news 2024-07-11
SatsAllDay pr #1263 #1112 medium 1 225k weareallsatoshi@getalby.com 2024-07-31
OneOneSeven117 issue #1272 #1268 easy 10k OneOneSeven@stacker.news 2024-07-31
aniskhalfallah pr #1264 #1226 good-first-issue 20k aniskhalfallah@stacker.news 2024-07-31
Gudnessuche issue #1264 #1226 good-first-issue 2k everythingsatoshi@getalby.com 2024-08-10
aniskhalfallah pr #1289 easy 100k aniskhalfallah@blink.sv 2024-08-12
riccardobl pr #1293 #1142 medium high 500k rblb@getalby.com 2024-08-18
tsmith123 pr #1306 #832 medium 250k stickymarch60@walletofsatoshi.com 2024-08-20
riccardobl pr #1311 #864 medium high pending unrelated refactor 500k rblb@getalby.com 2024-08-27
brugeman issue #1311 #864 medium high 50k brugeman@stacker.news 2024-08-27
riccardobl pr #1342 #1141 hard high pending unrelated rearchitecture 1m rblb@getalby.com 2024-09-09
SatsAllDay issue #1368 #1331 medium 25k weareallsatoshi@getalby.com 2024-09-16
benalleng helpfulness #1368 #1170 medium did a lot of it in #1175 25k BenAllenG@stacker.news 2024-09-16
humble-GOAT issue #1412 #1407 good-first-issue 2k humble_GOAT@stacker.news 2024-09-18
felipebueno issue #1425 #986 medium 25k felipebueno@getalby.com 2024-09-26
riccardobl pr #1373 #1304 hard high 2m bolt11 2024-10-01
tsmith123 pr #1428 #1397 easy 1 superceded 90k stickymarch60@walletofsatoshi.com 2024-10-02
toyota-corolla0 pr #1449 good-first-issue 20k toyota_corolla0@stacker.news 2024-10-02
toyota-corolla0 pr #1455 #1437 good-first-issue 20k toyota_corolla0@stacker.news 2024-10-02
SouthKoreaLN issue #1436 easy 10k south_korea_ln@stacker.news 2024-10-02
TonyGiorgio issue #1462 easy urgent 30k TonyGiorgio@stacker.news 2024-10-07
hkarani issue #1369 #1458 good-first-issue 2k asterisk32@stacker.news 2024-10-21
toyota-corolla0 pr #1369 #1458 good-first-issue 20k toyota_corolla0@stacker.news 2024-10-20
Soxasora pr #1593 #1569 good-first-issue 20k soxasora@blink.sv 2024-11-19
Soxasora pr #1599 #1258 medium 250k soxasora@blink.sv 2024-11-19
aegroto pr #1585 #1522 easy high 1 180k aegroto@blink.sv 2024-11-19
sig47 issue #1585 #1522 easy high 1 18k siggy47@stacker.news 2024-11-19
aegroto pr #1583 #1572 easy 2 80k aegroto@blink.sv 2024-11-19
Soxasora pr #1617 #1616 easy 100k soxasora@blink.sv 2024-11-20
Soxasora issue #1617 #1616 easy 10k soxasora@blink.sv 2024-11-20
AndreaDiazCorreia helpfulness #1605 #1566 good-first-issue tried in pr 2k andrea@lawallet.ar 2024-11-20
Soxasora pr #1653 medium determined unecessary 250k soxasora@blink.sv 2024-12-07
Soxasora pr #1659 #1657 easy 100k soxasora@blink.sv 2024-12-07
sig47 issue #1659 #1657 easy 10k siggy47@stacker.news 2024-12-07
Gudnessuche issue #1662 #1661 good-first-issue 2k everythingsatoshi@getalby.com 2024-12-07
aegroto pr #1589 #1586 easy 100k aegroto@blink.sv 2024-12-07
aegroto issue #1589 #1586 easy 10k aegroto@blink.sv 2024-12-07
aegroto pr #1619 #914 easy 100k aegroto@blink.sv 2024-12-07
felipebueno pr #1620 medium 1 225k felipebueno@getalby.com 2024-12-09
Soxasora pr #1647 #1645 easy 100k soxasora@blink.sv 2024-12-07
Soxasora pr #1667 #1568 easy 100k soxasora@blink.sv 2024-12-07
aegroto pr #1633 #1471 easy 1 90k aegroto@blink.sv 2024-12-07
Darth-Coin issue #1649 #1421 medium 25k darthcoin@stacker.news 2024-12-07
Soxasora pr #1685 medium 250k soxasora@blink.sv 2024-12-07
aegroto pr #1606 #1242 medium 250k aegroto@blink.sv 2024-12-07
sfr0xyz issue #1696 #1196 good-first-issue 2k sefiro@getalby.com 2024-12-10
Soxasora pr #1794 #756 hard urgent includes #411 3m bolt11 2025-01-09
Soxasora pr #1786 #363 easy 100k bolt11 2025-01-09
Soxasora pr #1768 #1186 medium-hard 500k bolt11 2025-01-09
Soxasora pr #1750 #1035 medium 250k bolt11 2025-01-09
SatsAllDay issue #1794 #411 hard high 200k weareallsatoshi@getalby.com 2025-01-20
felipebueno issue #1786 #363 easy 10k felipebueno@blink.sv 2025-01-27
cyphercosmo pr #1745 #1648 good-first-issue 2 16k cyphercosmo@getalby.com 2025-01-27
Radentor issue #1768 #1186 medium-hard 50k revisedbird84@walletofsatoshi.com 2025-01-27
Soxasora pr #1841 #1692 good-first-issue 20k soxasora@blink.sv 2025-01-27
Soxasora pr #1839 #1790 easy 1 90k soxasora@blink.sv 2025-01-27
Soxasora pr #1820 #1819 easy 1 90k soxasora@blink.sv 2025-01-27
SatsAllDay issue #1820 #1819 easy 1 9k weareallsatoshi@getalby.com 2025-01-27
Soxasora pr #1814 #1736 easy 100k soxasora@blink.sv 2025-01-27
jason-me pr #1857 easy 100k rrbtc@vlt.ge 2025-02-08
ed-kung pr #1901 #323 good-first-issue 20k simplestacker@getalby.com 2025-02-14
Scroogey-SN pr #1911 #1905 good-first-issue 1 18k Scroogey@coinos.io 2025-03-10
Scroogey-SN pr #1928 #1924 good-first-issue 20k Scroogey@coinos.io 2025-03-10
dtonon issue #1928 #1924 good-first-issue 2k tips@dtonon.com 2025-04-16
ed-kung pr #1926 #1914 medium-hard 500k simplestacker@getalby.com 2025-03-10
ed-kung issue #1926 #1914 medium-hard 50k simplestacker@getalby.com 2025-03-10
ed-kung pr #1926 #1927 easy 100k simplestacker@getalby.com 2025-03-10
ed-kung issue #1926 #1927 easy 10k simplestacker@getalby.com 2025-03-10
ed-kung issue #1913 #1890 good-first-issue 2k simplestacker@getalby.com 2025-03-10
Scroogey-SN pr #1930 #1167 good-first-issue 20k Scroogey@coinos.io 2025-03-10
itsrealfake issue #1930 #1167 good-first-issue 2k smallimagination100035@getalby.com 2025-04-02
Scroogey-SN pr #1948 #1849 medium urgent 750k Scroogey@coinos.io 2025-03-10
felipebueno issue #1947 #1945 good-first-issue 2k felipebueno@blink.sv 2025-03-10
ed-kung pr #1952 #1951 easy 100k simplestacker@getalby.com 2025-03-10
ed-kung issue #1952 #1951 easy 10k simplestacker@getalby.com 2025-03-10
Scroogey-SN pr #1973 #1959 good-first-issue 20k Scroogey@coinos.io 2025-04-02
benthecarman issue #1953 #1950 good-first-issue 2k me@benthecarman.com 2025-04-16
ed-kung pr #2012 #2004 easy 100k simplestacker@getalby.com 2025-04-02
ed-kung issue #2012 #2004 easy 10k simplestacker@getalby.com 2025-04-02
ed-kung pr #1993 #1982 good-first-issue 20k simplestacker@getalby.com 2025-04-02
rideandslide issue #1993 #1982 good-first-issue 2k koiora@getalby.com 2025-04-02
ed-kung pr #1972 #1254 good-first-issue 20k simplestacker@getalby.com 2025-04-02
SatsAllDay issue #1972 #1254 good-first-issue 2k weareallsatoshi@getalby.com 2025-04-02
ed-kung pr #1962 #1343 good-first-issue 20k simplestacker@getalby.com 2025-04-02
ed-kung pr #1962 #1217 good-first-issue 20k simplestacker@getalby.com 2025-04-02
ed-kung pr #1962 #866 easy 100k simplestacker@getalby.com 2025-04-02
felipebueno issue #1962 #866 easy 10k felipebueno@blink.sv 2025-04-02
cointastical issue #1962 #1217 good-first-issue 2k Cointastical@getAlby.com 2025-04-16
Scroogey-SN pr #1975 #1964 good-first-issue 20k Scroogey@coinos.io 2025-04-02
rideandslide issue #1986 #1985 good-first-issue 2k koiora@getalby.com 2025-04-02
kristapsk issue #1976 #841 good-first-issue 2k kristapsk@stacker.news 2025-04-16
ed-kung pr #2070 #2061 good-first-issue 20k simplestacker@getalby.com 2025-04-16
ed-kung issue #2070 #2061 good-first-issue 2k simplestacker@getalby.com 2025-04-16
ed-kung pr #2070 #2058 easy 100k simplestacker@getalby.com 2025-04-16
ed-kung pr #2070 #2047 medium-hard 500k simplestacker@getalby.com 2025-04-16
SouthKoreaLN pr #2068 #2064 good-first-issue 20k south_korea_ln@stacker.news 2025-04-16
kepford issue #2068 #2064 good-first-issue 2k penalwink141@minibits.cash 2025-04-16
SouthKoreaLN pr #2069 #1990 good-first-issue 20k south_korea_ln@stacker.news 2025-04-16
cointastical issue #2071 #1475 good-first-issue 2k Cointastical@getAlby.com 2025-04-16
brymut pr #2082 #2051 easy low 3 35k brymut@stacker.news 2025-04-16
abhiShandy pr #2083 #1270 good-first-issue 20k abhishandy@stacker.news 2025-04-16
cointastical issue #2083 #1270 good-first-issue 2k Cointastical@getAlby.com 2025-04-16
brymut pr #2093 #1991 easy 100k brymut@stacker.news 2025-04-16
brymut pr #2100 #2090 easy 3 70k brymut@stacker.news 2025-04-24
abhiShandy pr #2109 #1221 good-first-issue 20k abhishandy@stacker.news 2025-04-24
Gudnessuche issue #2109 #1221 good-first-issue 2k everythingsatoshi@getalby.com 2025-04-24
brymut pr #2153 #2087 easy 100k brymut@stacker.news 2025-05-13
brymut pr #2152 #2142 good-first-issue 1 18k brymut@stacker.news 2025-05-13
m0wer pr #2124 #992 medium 1 225k klk@stacker.news 2025-05-13
ed-kung issue #2072 #2043 easy 10k simplestacker@getalby.com 2025-05-15
ed-kung helpfulness #2072 #2043 easy 10k simplestacker@getalby.com 2025-05-15
SouthKoreaLN pr #2072 #2043 easy 100k south_korea_ln@stacker.news 2025-05-13
m0wer pr #2135 #1391 easy 1 more difficult than planned 150k klk@stacker.news 2025-05-13
sutt pr #2162 #2161 good-first-issue 20k bounty_hunter@stacker.news 2025-05-15
sutt issue #2162 #2161 good-first-issue 2k bounty_hunter@stacker.news 2025-05-15
brymut pr #2171 #2164 easy 100k brymut@stacker.news 2025-05-21
SouthKoreaLN issue #2171 #2164 easy 10k south_korea_ln@stacker.news 2025-05-21
brymut pr #2175 #2173 good-first-issue 20k brymut@stacker.news 2025-05-21
sutt pr #2185 #2183 easy high 200k bounty_hunter@stacker.news 2025-06-19
sutt issue #2185 #2183 easy high 20k bounty_hunter@stacker.news 2025-06-06
axelvyrn advisory #2205 GHSA-x2xp-x867-4jfc 100k holonite@speed.app 2025-06-06
brymut pr #2184 #2165 easy 100k brymut@stacker.news 2025-06-06
sutt pr #2190 #2187 easy 100k bounty_hunter@stacker.news 2025-06-06
sutt issue #2190 #2187 easy 10k bounty_hunter@stacker.news 2025-06-06
sutt pr #2192 #2188 medium 250k bounty_hunter@stacker.news 2025-06-19
sutt issue #2192 #2188 medium 25k bounty_hunter@stacker.news 2025-06-19
abhiShandy pr #2195 #2181 good-first-issue 1 18k abhishandy@stacker.news 2025-06-13
brymut pr #2191 #1409 medium 2 200k brymut@stacker.news 2025-06-13
SatsAllDay issue #2191 #1409 medium 20k weareallsatoshi@getalby.com 2025-06-13
ed-kung pr #2217 #2039 medium 250k simplestacker@getalby.com 2025-06-18
ed-kung issue #2217 #2039 medium 25k simplestacker@getalby.com 2025-06-18
axelvyrn pr #2220 #2198 good-first-issue 5 10k holonite@speed.app 2025-06-18
axelvyrn issue #2220 #2198 good-first-issue 1k holonite@speed.app 2025-06-18
brymut pr #2221 #2204 good-first-issue 20k brymut@stacker.news 2025-06-18
brymut pr #2235 #2233 good-first-issue 20k brymut@stacker.news 2025-06-18
brymut pr #2250 #2106 good-first-issue 20k brymut@stacker.news 2025-07-12
SouthKoreaLN issue #2267 #2164 easy 10k south_korea_ln@stacker.news 2025-07-12
pory-gone pr #2316 #2277 good-first-issue 20k pory@porygone.xyz 2025-08-01
brymut pr #2326 good-first-issue 20k brymut@stacker.news 2025-07-31
brymut pr #2332 #2276 easy 100k brymut@stacker.news 2025-07-31
ed-kung pr #2373 #2371 good-first-issue 20k simplestacker@getalby.com 2025-07-31
ed-kung issue #2373 #2371 good-first-issue 2k simplestacker@getalby.com 2025-07-31
pory-gone pr #2381 #2370 good-first-issue 20k pory@porygone.xyz ???
pory-gone pr #2413 #2361 easy 100k pory@porygone.xyz ???

View File

@ -11,7 +11,7 @@ RUN npm ci
COPY . .
ADD https://deb.debian.org/debian/pool/main/f/fonts-noto-color-emoji/fonts-noto-color-emoji_0~20200916-1_all.deb fonts-noto-color-emoji.deb
ADD http://ftp.de.debian.org/debian/pool/main/f/fonts-noto-color-emoji/fonts-noto-color-emoji_0~20200916-1_all.deb fonts-noto-color-emoji.deb
RUN dpkg -i fonts-noto-color-emoji.deb
CMD [ "node", "index.js" ]
USER pptruser
USER pptruser

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.20.0",
"express": "^4.18.2",
"puppeteer": "^20.8.2"
},
"type": "module"

View File

@ -4,7 +4,6 @@ import { useAccordionButton } from 'react-bootstrap/AccordionButton'
import ArrowRight from '@/svgs/arrow-right-s-fill.svg'
import ArrowDown from '@/svgs/arrow-down-s-fill.svg'
import { useContext, useEffect, useState } from 'react'
import classNames from 'classnames'
const KEY_ID = '0'
@ -31,7 +30,7 @@ function ContextAwareToggle ({ children, headerColor = 'var(--theme-grey)', even
)
}
export default function AccordianItem ({ header, body, className, headerColor = 'var(--theme-grey)', show }) {
export default function AccordianItem ({ header, body, headerColor = 'var(--theme-grey)', show }) {
const [activeKey, setActiveKey] = useState()
useEffect(() => {
@ -44,9 +43,9 @@ export default function AccordianItem ({ header, body, className, headerColor =
return (
<Accordion defaultActiveKey={activeKey} activeKey={activeKey} onSelect={handleOnSelect}>
<ContextAwareToggle show={show} eventKey={KEY_ID} headerColor={headerColor}><div style={{ color: headerColor }}>{header}</div></ContextAwareToggle>
<Accordion.Collapse eventKey={KEY_ID} className={classNames('mt-2', className)}>
<div key={activeKey}>{body}</div>
<ContextAwareToggle show={show} eventKey={KEY_ID}><div style={{ color: headerColor }}>{header}</div></ContextAwareToggle>
<Accordion.Collapse eventKey={KEY_ID} className='mt-2'>
<div>{body}</div>
</Accordion.Collapse>
</Accordion>
)

View File

@ -1,98 +0,0 @@
import { useRouter } from 'next/router'
import { USER_ID } from '@/lib/constants'
import { USER } from '@/fragments/users'
import { useQuery } from '@apollo/client'
import { UserListRow } from '@/components/user-list'
import useCookie from '@/components/use-cookie'
import Link from 'next/link'
import AddIcon from '@/svgs/add-fill.svg'
import { cookieOptions, MULTI_AUTH_ANON, MULTI_AUTH_LIST, MULTI_AUTH_POINTER } from '@/lib/auth'
const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')
export const nextAccount = async () => {
const { status } = await fetch('/api/next-account', { credentials: 'include' })
// if status is 302, this means the server was able to switch us to the next available account
return status === 302
}
export default function SwitchAccountList () {
const router = useRouter()
const accounts = useAccounts()
const [pointerCookie] = useCookie(MULTI_AUTH_POINTER)
return (
<>
<div className='my-2'>
<div className='d-flex flex-column flex-wrap mt-2 mb-3'>
<h4 className='text-muted'>Accounts</h4>
<AccountListRow
account={{ id: USER_ID.anon, name: 'anon' }}
selected={pointerCookie === MULTI_AUTH_ANON}
showHat={false}
/>
{
accounts.map((account) =>
<AccountListRow
key={account.id}
account={account}
selected={Number(pointerCookie) === account.id}
showHat={false}
/>)
}
</div>
<Link
href={{
pathname: '/login',
query: { callbackUrl: window.location.origin + router.asPath, multiAuth: true }
}}
className='text-reset fw-bold'
>
<AddIcon height={20} width={20} /> another account
</Link>
</div>
</>
)
}
const AccountListRow = ({ account, selected, ...props }) => {
const router = useRouter()
const [, setPointerCookie] = useCookie(MULTI_AUTH_POINTER)
// fetch updated names and photo ids since they might have changed since we were issued the JWTs
const { data, error } = useQuery(USER, { variables: { id: account.id } })
if (error) console.error(`query for user ${account.id} failed:`, error)
const name = data?.user?.name || account.name
const photoId = data?.user?.photoId || account.photoId
const onClick = async (e) => {
// prevent navigation
e.preventDefault()
// update pointer cookie
const options = cookieOptions({ httpOnly: false })
const anon = account.id === USER_ID.anon
setPointerCookie(anon ? MULTI_AUTH_ANON : account.id, options)
// reload whatever page we're on to avoid any bugs due to missing authorization etc.
router.reload()
}
return (
<div className='d-flex flex-row'>
<UserListRow
user={{ ...account, photoId, name }}
className='d-flex align-items-center me-2'
selected={selected}
{...props}
onNymClick={onClick}
/>
</div>
)
}
export const useAccounts = () => {
const [listCookie] = useCookie(MULTI_AUTH_LIST)
return listCookie ? JSON.parse(b64Decode(listCookie)) : []
}

View File

@ -15,19 +15,12 @@ export default function ActionTooltip ({ children, notForm, disable, overlayText
<OverlayTrigger
placement={placement || 'bottom'}
overlay={
<Tooltip style={{ position: 'fixed' }}>
<Tooltip>
{overlayText}
</Tooltip>
}
trigger={['hover', 'focus']}
show={formik?.isSubmitting ? false : undefined}
popperConfig={{
modifiers: {
preventOverflow: {
enabled: false
}
}
}}
>
<span>
{children}

View File

@ -1,20 +1,16 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useState, useEffect } from 'react'
import AccordianItem from './accordian-item'
import { Input, InputUserSuggest, VariableInput, Checkbox } from './form'
import InputGroup from 'react-bootstrap/InputGroup'
import { BOOST_MIN, BOOST_MAX, BOOST_MULT, MAX_FORWARDS, SSR } from '@/lib/constants'
import { BOOST_MIN, BOOST_MULT, MAX_FORWARDS } from '@/lib/constants'
import { DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr'
import Info from './info'
import { abbrNum, numWithUnits } from '@/lib/format'
import { numWithUnits } from '@/lib/format'
import styles from './adv-post-form.module.css'
import { useMe } from './me'
import { useFeeButton } from './fee-button'
import { useRouter } from 'next/router'
import { useFormikContext } from 'formik'
import { gql, useQuery } from '@apollo/client'
import useDebounceCallback from './use-debounce-callback'
import { Button } from 'react-bootstrap'
import classNames from 'classnames'
const EMPTY_FORWARD = { nym: '', pct: '' }
@ -30,154 +26,9 @@ const FormStatus = {
ERROR: 'error'
}
export function BoostHelp () {
return (
<ol style={{ lineHeight: 1.25 }}>
<li>Boost ranks items higher based on the amount</li>
<li>The highest boost in a territory over the last 30 days is pinned to the top of the territory</li>
<li>The highest boost across all territories over the last 30 days is pinned to the top of the homepage</li>
<li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li>
<li>The maximum boost is {numWithUnits(BOOST_MAX, { abbreviate: false })}</li>
<li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to a zap-vote from a maximally trusted stacker (very rare)
<ul>
<li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like five zap-votes from a maximally trusted stacker</li>
</ul>
</li>
<li>boost can take a few minutes to show higher ranking in feed</li>
<li>100% of boost goes to the territory founder and top stackers as rewards</li>
</ol>
)
}
export function BoostInput ({ onChange, ...props }) {
const feeButton = useFeeButton()
let merge
if (feeButton) {
({ merge } = feeButton)
}
return (
<Input
label={
<div className='d-flex align-items-center'>boost
<Info>
<BoostHelp />
</Info>
</div>
}
name='boost'
onChange={(_, e) => {
merge?.({
boost: {
term: `+ ${e.target.value}`,
label: 'boost',
op: '+',
modifier: cost => cost + Number(e.target.value)
}
})
onChange && onChange(_, e)
}}
hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
{...props}
/>
)
}
const BoostMaxes = ({ subName, homeMax, subMax, boost, updateBoost }) => {
return (
<div className='d-flex flex-row mb-2'>
<Button
className={classNames(styles.boostMax, 'me-2', homeMax + BOOST_MULT <= (boost || 0) && 'invisible')}
size='sm'
onClick={() => updateBoost(homeMax + BOOST_MULT)}
>
{abbrNum(homeMax + BOOST_MULT)} <small>top of homepage</small>
</Button>
{subName &&
<Button
className={classNames(styles.boostMax, subMax + BOOST_MULT <= (boost || 0) && 'invisible')}
size='sm'
onClick={() => updateBoost(subMax + BOOST_MULT)}
>
{abbrNum(subMax + BOOST_MULT)} <small>top of ~{subName}</small>
</Button>}
</div>
)
}
// act means we are adding to existing boost
export function BoostItemInput ({ item, sub, act = false, ...props }) {
// act adds boost to existing boost
const existingBoost = act ? Number(item?.boost || 0) : 0
const [boost, setBoost] = useState(act ? 0 : Number(item?.boost || 0))
const { data, previousData, refetch } = useQuery(gql`
query BoostPosition($sub: String, $id: ID, $boost: Int) {
boostPosition(sub: $sub, id: $id, boost: $boost) {
home
sub
homeMaxBoost
subMaxBoost
}
}`,
{
variables: { sub: item?.subName || sub?.name, boost: existingBoost + boost, id: item?.id },
fetchPolicy: 'cache-and-network',
skip: !!item?.parentId || SSR
})
const getPositionDebounce = useDebounceCallback((...args) => refetch(...args), 1000, [refetch])
const updateBoost = useCallback((boost) => {
const boostToUse = Number(boost || 0)
setBoost(boostToUse)
getPositionDebounce({ sub: item?.subName || sub?.name, boost: Number(existingBoost + boostToUse), id: item?.id })
}, [getPositionDebounce, item?.id, item?.subName, sub?.name, existingBoost])
const dat = data || previousData
const boostMessage = useMemo(() => {
if (!item?.parentId && boost >= BOOST_MULT) {
if (dat?.boostPosition?.home || dat?.boostPosition?.sub || boost > dat?.boostPosition?.homeMaxBoost || boost > dat?.boostPosition?.subMaxBoost) {
const boostPinning = []
if (dat?.boostPosition?.home || boost > dat?.boostPosition?.homeMaxBoost) {
boostPinning.push('homepage')
}
if ((item?.subName || sub?.name) && (dat?.boostPosition?.sub || boost > dat?.boostPosition?.subMaxBoost)) {
boostPinning.push(`~${item?.subName || sub?.name}`)
}
return `pins to the top of ${boostPinning.join(' and ')}`
}
}
return 'ranks posts higher based on the amount'
}, [boost, dat?.boostPosition?.home, dat?.boostPosition?.sub, item?.subName, sub?.name])
return (
<>
<BoostInput
hint={<span className='text-muted'>{boostMessage}</span>}
onChange={(_, e) => {
if (e.target.value >= 0) {
updateBoost(Number(e.target.value))
}
}}
overrideValue={boost}
{...props}
groupClassName='mb-1'
/>
{!item?.parentId &&
<BoostMaxes
subName={item?.subName || sub?.name}
homeMax={(dat?.boostPosition?.homeMaxBoost || 0) - existingBoost}
subMax={(dat?.boostPosition?.subMaxBoost || 0) - existingBoost}
boost={existingBoost + boost}
updateBoost={updateBoost}
/>}
</>
)
}
export default function AdvPostForm ({ children, item, sub, storageKeyPrefix }) {
const { me } = useMe()
export default function AdvPostForm ({ children, item, storageKeyPrefix }) {
const me = useMe()
const { merge } = useFeeButton()
const router = useRouter()
const [itemType, setItemType] = useState()
const formik = useFormikContext()
@ -198,7 +49,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
for (let i = 0; i < MAX_FORWARDS; i++) {
['nym', 'pct'].forEach(key => {
const value = window.localStorage.getItem(`${storageKeyPrefix}-forward[${i}].${key}`)
if (value !== undefined && value !== null) {
if (value) {
formik?.setFieldValue(`forward[${i}].${key}`, value)
}
})
@ -260,7 +111,39 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
body={
<>
{children}
<BoostItemInput item={item} sub={sub} />
<Input
label={
<div className='d-flex align-items-center'>boost
<Info>
<ol className='fw-bold'>
<li>Boost ranks posts higher temporarily based on the amount</li>
<li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li>
<li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to one trusted upvote
<ul>
<li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like 5 votes</li>
</ul>
</li>
<li>The decay of boost "votes" increases at 1.25x the rate of organic votes
<ul>
<li>i.e. boost votes fall out of ranking faster</li>
</ul>
</li>
<li>100% of sats from boost are given back to top stackers as rewards</li>
</ol>
</Info>
</div>
}
name='boost'
onChange={(_, e) => merge({
boost: {
term: `+ ${e.target.value}`,
label: 'boost',
modifier: cost => cost + Number(e.target.value)
}
})}
hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<VariableInput
label='forward sats to'
name='forward'
@ -269,7 +152,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
emptyItem={EMPTY_FORWARD}
hint={<span className='text-muted'>Forward sats to up to 5 other stackers. Any remaining sats go to you.</span>}
>
{({ index, AppendColumn }) => {
{({ index, placeholder }) => {
return (
<div key={index} className='d-flex flex-row'>
<InputUserSuggest
@ -286,7 +169,6 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
max={100}
append={<InputGroup.Text className='text-monospace'>%</InputGroup.Text>}
groupClassName={`${styles.percent} mb-0`}
AppendColumn={AppendColumn}
/>
</div>
)
@ -297,7 +179,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
label={
<div className='d-flex align-items-center'>crosspost to nostr
<Info>
<ul>
<ul className='fw-bold'>
{renderCrosspostDetails(itemType)}
<li>requires NIP-07 extension for signing</li>
<li>we use your NIP-05 relays if set</li>

View File

@ -9,11 +9,4 @@
display: flex;
flex: 0 1 fit-content;
height: fit-content;
}
.boostMax small {
font-weight: 400;
margin-left: 0.25rem;
margin-right: 0.25rem;
opacity: 0.5;
}

View File

@ -1,275 +0,0 @@
import { useCallback, createContext, useContext, useState, useEffect } from 'react'
import Particles from 'react-particles'
import { loadFireworksPreset } from 'tsparticles-preset-fireworks'
import styles from './fireworks.module.css'
import {
rgbToHsl,
setRangeValue,
stringToRgb
} from 'tsparticles-engine'
import useDarkMode from '@/components/dark-mode'
export const FireworksContext = createContext({
strike: () => {}
})
export const FireworksConsumer = FireworksContext.Consumer
export function useFireworks () {
const { strike } = useContext(FireworksContext)
return strike
}
export function FireworksProvider ({ children }) {
const [cont, setCont] = useState()
const [context, setContext] = useState({ strike: () => {} })
const [darkMode] = useDarkMode()
useEffect(() => {
setContext({
strike: () => {
const should = window.localStorage.getItem('lnAnimate') || 'yes'
if (should !== 'yes') return false
cont?.addEmitter(
{
direction: 'top',
life: {
count: 1,
duration: 0.1,
delay: 0.1
},
rate: {
delay: 0,
quantity: 1
},
size: {
width: 10,
height: 0
},
position: {
y: 100,
x: 50
}
})
return true
}
})
}, [cont])
const particlesLoaded = useCallback((container) => {
setCont(container)
}, [])
const particlesInit = useCallback(async engine => {
// you can initiate the tsParticles instance (engine) here, adding custom shapes or presets
// this loads the tsparticles package bundle, it's the easiest method for getting everything ready
// starting from v2 you can add only the features you need reducing the bundle size
await loadFireworksPreset(engine)
}, [])
return (
<FireworksContext.Provider value={context}>
<Particles
className={styles.fireworks}
init={particlesInit}
loaded={particlesLoaded}
options={darkMode ? darkOptions : lightOptions}
/>
{children}
</FireworksContext.Provider>
)
}
const fixRange = (value, min, max) => {
const diffSMax = value.max > max ? value.max - max : 0
let res = setRangeValue(value)
if (diffSMax) {
res = setRangeValue(value.min - diffSMax, max)
}
const diffSMin = value.min < min ? value.min : 0
if (diffSMin) {
res = setRangeValue(0, value.max + diffSMin)
}
return res
}
const fireworksOptions = ['#ff595e', '#ffca3a', '#8ac926', '#1982c4', '#6a4c93']
.map((color) => {
const rgb = stringToRgb(color)
if (!rgb) {
return undefined
}
const hsl = rgbToHsl(rgb)
const sRange = fixRange({ min: hsl.s - 30, max: hsl.s + 30 }, 0, 100)
const lRange = fixRange({ min: hsl.l - 30, max: hsl.l + 30 }, 0, 100)
return {
color: {
value: {
h: hsl.h,
s: sRange,
l: lRange
}
},
stroke: {
width: 0
},
number: {
value: 0
},
opacity: {
value: {
min: 0.1,
max: 1
},
animation: {
enable: true,
speed: 0.7,
sync: false,
startValue: 'max',
destroy: 'min'
}
},
shape: {
type: 'circle'
},
size: {
value: { min: 1, max: 2 },
animation: {
enable: true,
speed: 5,
count: 1,
sync: false,
startValue: 'min',
destroy: 'none'
}
},
life: {
count: 1,
duration: {
value: {
min: 1,
max: 2
}
}
},
move: {
decay: { min: 0.075, max: 0.1 },
enable: true,
gravity: {
enable: true,
inverse: false,
acceleration: 5
},
speed: { min: 5, max: 15 },
direction: 'none',
outMode: {
top: 'destroy',
default: 'bounce'
}
}
}
})
.filter((t) => t !== undefined)
const particlesOptions = (theme) => ({
number: {
value: 0
},
destroy: {
mode: 'split',
bounds: {
top: { min: 5, max: 40 }
},
split: {
sizeOffset: false,
count: 1,
factor: {
value: 0.333333
},
rate: {
value: { min: 75, max: 150 }
},
particles: fireworksOptions
}
},
life: {
count: 1
},
shape: {
type: 'line'
},
size: {
value: {
min: 0.1,
max: 50
},
animation: {
enable: true,
sync: true,
speed: 90,
startValue: 'max',
destroy: 'min'
}
},
rotate: {
path: true
},
stroke: {
color: {
value: theme === 'dark' ? '#fff' : '#aaa'
},
width: 1
},
move: {
enable: true,
gravity: {
acceleration: 15,
enable: true,
inverse: true,
maxSpeed: 100
},
speed: {
min: 10,
max: 20
},
outModes: {
default: 'split',
top: 'none'
},
trail: {
fillColor: theme === 'dark' ? '#000' : '#f5f5f7',
enable: true,
length: 10
}
}
})
const darkOptions = {
fullScreen: { enable: true, zIndex: -1 },
detectRetina: true,
background: {
color: '#000',
opacity: 0
},
fpsLimit: 120,
emitters: [],
particles: particlesOptions('dark')
}
const lightOptions = {
fullScreen: { enable: true, zIndex: -1 },
detectRetina: true,
background: {
color: '#fff',
opacity: 0
},
fpsLimit: 120,
emitters: [],
particles: particlesOptions('light')
}

View File

@ -1,8 +0,0 @@
.fireworks {
z-index: 0;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}

View File

@ -1,72 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { useMe } from '@/components/me'
import { randInRange } from '@/lib/rand'
import { LightningProvider, useLightning } from './lightning'
// import { FireworksProvider, useFireworks } from './fireworks'
// import { SnowProvider, useSnow } from './snow'
const [SelectedAnimationProvider, useSelectedAnimation] = [
LightningProvider, useLightning
// FireworksProvider, useFireworks
// SnowProvider, useSnow // TODO: the snow animation doesn't seem to work anymore
]
export function AnimationProvider ({ children }) {
return (
<SelectedAnimationProvider>
<AnimationHooks>
{children}
</AnimationHooks>
</SelectedAnimationProvider>
)
}
export function useAnimation () {
const animate = useSelectedAnimation()
return useCallback(() => {
const should = window.localStorage.getItem('lnAnimate') || 'yes'
if (should !== 'yes') return false
animate()
return true
}, [animate])
}
export function useAnimationEnabled () {
const [enabled, setEnabled] = useState(undefined)
useEffect(() => {
const enabled = window.localStorage.getItem('lnAnimate') || 'yes'
setEnabled(enabled === 'yes')
}, [])
const toggleEnabled = useCallback(() => {
setEnabled(enabled => {
const newEnabled = !enabled
window.localStorage.setItem('lnAnimate', newEnabled ? 'yes' : 'no')
return newEnabled
})
}, [])
return [enabled, toggleEnabled]
}
function AnimationHooks ({ children }) {
const { me } = useMe()
const animate = useAnimation()
useEffect(() => {
if (me || window.localStorage.getItem('striked') || window.localStorage.getItem('lnAnimated')) return
const timeout = setTimeout(() => {
const animated = animate()
if (animated) {
window.localStorage.setItem('lnAnimated', 'yep')
}
}, randInRange(3000, 10000))
return () => clearTimeout(timeout)
}, [me?.id, animate])
return children
}

View File

@ -0,0 +1,61 @@
import { InputGroup } from 'react-bootstrap'
import { Checkbox, Input } from './form'
import { useMe } from './me'
import { useEffect, useState } from 'react'
import { isNumber } from 'mathjs'
function autoWithdrawThreshold ({ me }) {
return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000
}
export function autowithdrawInitial ({ me, priority = false }) {
return {
priority,
autoWithdrawThreshold: autoWithdrawThreshold({ me }),
autoWithdrawMaxFeePercent: isNumber(me?.privates?.autoWithdrawMaxFeePercent) ? me?.privates?.autoWithdrawMaxFeePercent : 1
}
}
export function AutowithdrawSettings ({ priority }) {
const me = useMe()
const threshold = autoWithdrawThreshold({ me })
const [sendThreshold, setSendThreshold] = useState(Math.max(Math.floor(threshold / 10), 1))
useEffect(() => {
setSendThreshold(Math.max(Math.floor(threshold / 10), 1))
}, [autoWithdrawThreshold])
return (
<>
<Checkbox
label='make default autowithdraw method'
id='priority'
name='priority'
/>
<div className='my-4 border border-3 rounded'>
<div className='p-3'>
<h3 className='text-center text-muted'>desired balance</h3>
<h6 className='text-center pb-3'>applies globally to all autowithdraw methods</h6>
<Input
label='desired balance'
name='autoWithdrawThreshold'
onChange={(formik, e) => {
const value = e.target.value
setSendThreshold(Math.max(Math.floor(value / 10), 1))
}}
hint={isNumber(sendThreshold) ? `will attempt auto-withdraw when your balance exceeds ${sendThreshold * 11} sats` : undefined}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<Input
label='max fee'
name='autoWithdrawMaxFeePercent'
hint='max fee as percent of withdrawal amount'
append={<InputGroup.Text>%</InputGroup.Text>}
/>
</div>
</div>
</>
)
}

View File

@ -5,19 +5,13 @@ import BootstrapForm from 'react-bootstrap/Form'
import EditImage from '@/svgs/image-edit-fill.svg'
import Moon from '@/svgs/moon-fill.svg'
import { useShowModal } from './modal'
import { FileUpload } from './file-upload'
import { gql, useMutation } from '@apollo/client'
import { ImageUpload } from './image'
export default function Avatar ({ onSuccess }) {
const [cropPhoto] = useMutation(gql`
mutation cropPhoto($photoId: ID!, $cropData: CropData) {
cropPhoto(photoId: $photoId, cropData: $cropData)
}
`)
const [uploading, setUploading] = useState()
const showModal = useShowModal()
const Body = ({ onClose, file, onSave }) => {
const Body = ({ onClose, file, upload }) => {
const [scale, setScale] = useState(1)
const ref = useRef()
@ -40,21 +34,13 @@ export default function Avatar ({ onSuccess }) {
/>
</BootstrapForm.Group>
<Button
onClick={async () => {
const rect = ref.current.getCroppingRect()
const img = new window.Image()
img.onload = async () => {
const cropData = {
...rect,
originalWidth: img.width,
originalHeight: img.height,
scale
onClick={() => {
ref.current.getImageScaledToCanvas().toBlob(blob => {
if (blob) {
upload(blob)
onClose()
}
// upload original to S3 along with crop data
await onSave(cropData)
}
img.src = URL.createObjectURL(file)
onClose()
}, 'image/jpeg')
}}
>save
</Button>
@ -62,54 +48,33 @@ export default function Avatar ({ onSuccess }) {
)
}
const startCrop = async (file, upload) => {
return new Promise((resolve, reject) =>
showModal(onClose => (
<Body
onClose={() => {
onClose()
resolve()
}}
file={file}
onSave={async (cropData) => {
setUploading(true)
try {
// upload original to S3
const photoId = await upload(file)
// crop it
const { data } = await cropPhoto({ variables: { photoId, cropData } })
const res = await fetch(data.cropPhoto)
const blob = await res.blob()
// create a file from the blob
const croppedImage = new File([blob], 'avatar.jpg', { type: 'image/jpeg' })
// upload the imgproxy cropped image
const croppedPhotoId = await upload(croppedImage)
onSuccess?.(croppedPhotoId)
setUploading(false)
} catch (e) {
console.error(e)
setUploading(false)
reject(e)
}
}}
/>
))
)
}
return (
<FileUpload
allow='image/*'
<ImageUpload
avatar
onError={e => {
console.log(e)
setUploading(false)
}}
onSelect={startCrop}
onSelect={(file, upload) => {
return new Promise((resolve, reject) =>
showModal(onClose => (
<Body
onClose={() => {
onClose()
resolve()
}}
file={file}
upload={async (blob) => {
await upload(blob)
resolve(blob)
}}
/>
)))
}}
onSuccess={({ id }) => {
onSuccess?.(id)
setUploading(false)
}}
onUpload={() => {
setUploading(true)
}}
@ -119,6 +84,6 @@ export default function Avatar ({ onSuccess }) {
? <Moon className='fill-white spin' />
: <EditImage className='fill-white' />}
</div>
</FileUpload>
</ImageUpload>
)
}

View File

@ -1,100 +0,0 @@
import { Fragment } from 'react'
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
import Tooltip from 'react-bootstrap/Tooltip'
import CowboyHatIcon from '@/svgs/cowboy.svg'
import AnonIcon from '@/svgs/spy-fill.svg'
import GunIcon from '@/svgs/revolver.svg'
import HorseIcon from '@/svgs/horse.svg'
import { numWithUnits } from '@/lib/format'
import { USER_ID } from '@/lib/constants'
import classNames from 'classnames'
export default function Badges ({ user, badge, className = 'ms-1', badgeClassName, spacingClassName = 'ms-1', height = 16, width = 16 }) {
if (!user || Number(user.id) === USER_ID.ad) return null
if (Number(user.id) === USER_ID.anon) {
return (
<BadgeTooltip overlayText='anonymous'>
<span className={className}><AnonIcon className={`${badgeClassName} align-middle`} height={height} width={width} /></span>
</BadgeTooltip>
)
}
const badges = []
const streak = user.optional.streak
if (streak !== null) {
badges.push({
icon: CowboyHatIcon,
overlayText: streak
? `${numWithUnits(streak, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' })}`
: 'new'
})
}
if (user.optional.hasRecvWallet) {
badges.push({
icon: HorseIcon,
overlayText: 'can receive sats'
})
}
if (user.optional.hasSendWallet) {
badges.push({
icon: GunIcon,
sizeDelta: 2,
overlayText: 'can send sats'
})
}
if (badges.length === 0) return null
return (
<span className={className}>
{badges.map(({ icon, overlayText, sizeDelta }, i) => (
<SNBadge
key={i}
user={user}
badge={badge}
overlayText={overlayText}
badgeClassName={classNames(badgeClassName, i > 0 && spacingClassName)}
IconForBadge={icon}
height={height}
width={width}
sizeDelta={sizeDelta}
/>
))}
</span>
)
}
function SNBadge ({ user, badge, overlayText, badgeClassName, IconForBadge, height = 16, width = 16, sizeDelta = 0 }) {
let Wrapper = Fragment
if (overlayText) {
Wrapper = ({ children }) => (
<BadgeTooltip overlayText={overlayText}>{children}</BadgeTooltip>
)
}
return (
<Wrapper>
<span><IconForBadge className={badgeClassName} height={height + sizeDelta} width={width + sizeDelta} /></span>
</Wrapper>
)
}
export function BadgeTooltip ({ children, overlayText, placement }) {
return (
<OverlayTrigger
placement={placement || 'bottom'}
overlay={
<Tooltip style={{ position: 'fixed' }}>
{overlayText}
</Tooltip>
}
trigger={['hover', 'focus']}
>
{children}
</OverlayTrigger>
)
}

View File

@ -5,9 +5,11 @@ import { useMe } from '@/components/me'
import { useMutation } from '@apollo/client'
import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
import { useToast } from '@/components/toast'
import { BALANCE_LIMIT_MSATS } from '@/lib/constants'
import { msatsToSats, numWithUnits } from '@/lib/format'
export function WelcomeBanner ({ Banner }) {
const { me } = useMe()
const me = useMe()
const toaster = useToast()
const [hidden, setHidden] = useState(true)
const handleClose = async () => {
@ -68,7 +70,7 @@ export function WelcomeBanner ({ Banner }) {
}
export function MadnessBanner ({ handleClose }) {
const { me } = useMe()
const me = useMe()
return (
<Alert className={styles.banner} key='info' variant='info' onClose={handleClose} dismissible>
<Alert.Heading>
@ -99,6 +101,44 @@ export function MadnessBanner ({ handleClose }) {
)
}
export function WalletLimitBanner () {
const me = useMe()
const limitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
if (!me || !limitReached) return
return (
<Alert className={styles.banner} key='info' variant='warning'>
<Alert.Heading>
Your wallet is over the current limit ({numWithUnits(msatsToSats(BALANCE_LIMIT_MSATS))})
</Alert.Heading>
<p className='mb-1'>
Deposits to your wallet from <strong>outside</strong> of SN are blocked.
</p>
<p>
Please spend or withdraw sats to restore full wallet functionality.
</p>
</Alert>
)
}
export function WalletSecurityBanner () {
return (
<Alert className={styles.banner} key='info' variant='warning'>
<Alert.Heading>
Wallet Security Disclaimer
</Alert.Heading>
<p className='mb-1'>
Your wallet's credentials are stored in the browser and never go to the server.<br />
However, you should definitely <strong>set a budget in your wallet</strong>.
</p>
<p>
Also, for the time being, you will have to reenter your credentials on other devices.
</p>
</Alert>
)
}
export function AuthBanner () {
return (
<Alert className={`${styles.banner} mt-0`} key='info' variant='danger'>

View File

@ -17,8 +17,7 @@ export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) {
id: `Item:${id}`,
fields: {
meBookmark: () => bookmarkItem.meBookmark
},
optimistic: true
}
})
}
}

View File

@ -1,65 +0,0 @@
import { useShowModal } from './modal'
import { useToast } from './toast'
import ItemAct from './item-act'
import AccordianItem from './accordian-item'
import { useMemo } from 'react'
import getColor from '@/lib/rainbow'
import BoostIcon from '@/svgs/arrow-up-double-line.svg'
import styles from './upvote.module.css'
import { BoostHelp } from './adv-post-form'
import { BOOST_MULT } from '@/lib/constants'
import classNames from 'classnames'
export default function Boost ({ item, className, ...props }) {
const { boost } = item
const [color, nextColor] = useMemo(() => [getColor(boost), getColor(boost + BOOST_MULT)], [boost])
const style = useMemo(() => ({
'--hover-fill': nextColor,
'--hover-filter': `drop-shadow(0 0 6px ${nextColor}90)`,
'--fill': color,
'--filter': `drop-shadow(0 0 6px ${color}90)`
}), [color, nextColor])
return (
<Booster
item={item} As={oprops =>
<div className='upvoteParent'>
<div
className={classNames(styles.upvoteWrapper, item.deletedAt && styles.noSelfTips)}
>
<BoostIcon
{...props}
{...oprops}
style={style}
width={26}
height={26}
className={classNames(styles.boost, className, boost && styles.boosted)}
/>
</div>
</div>}
/>
)
}
function Booster ({ item, As, children }) {
const toaster = useToast()
const showModal = useShowModal()
return (
<As
onClick={async () => {
try {
showModal(onClose =>
<ItemAct onClose={onClose} item={item} act='BOOST' step={BOOST_MULT}>
<AccordianItem header='what is boost?' body={<BoostHelp />} />
</ItemAct>)
} catch (error) {
toaster.danger('failed to boost item')
}
}}
>
{children}
</As>
)
}

View File

@ -1,16 +1,18 @@
import { Form, Input, MarkdownInput } from '@/components/form'
import { useApolloClient } from '@apollo/client'
import { useRouter } from 'next/router'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import InputGroup from 'react-bootstrap/InputGroup'
import useCrossposter from './use-crossposter'
import { bountySchema } from '@/lib/validate'
import { SubSelectInitial } from './sub-select'
import { normalizeForwards } from '@/lib/form'
import { useCallback } from 'react'
import { normalizeForwards, toastUpsertSuccessMessages } from '@/lib/form'
import { MAX_TITLE_LENGTH } from '@/lib/constants'
import { useMe } from './me'
import { useToast } from './toast'
import { ItemButtonBar } from './post'
import useItemSubmit from './use-item-submit'
import { UPSERT_BOUNTY } from '@/fragments/paidAction'
export function BountyForm ({
item,
@ -22,11 +24,75 @@ export function BountyForm ({
handleSubmit,
children
}) {
const router = useRouter()
const client = useApolloClient()
const { me } = useMe()
const me = useMe()
const toaster = useToast()
const crossposter = useCrossposter()
const schema = bountySchema({ client, me, existingBoost: item?.boost })
const [upsertBounty] = useMutation(
gql`
mutation upsertBounty(
$sub: String
$id: ID
$title: String!
$bounty: Int!
$text: String
$boost: Int
$forward: [ItemForwardInput]
$hash: String
$hmac: String
) {
upsertBounty(
sub: $sub
id: $id
title: $title
bounty: $bounty
text: $text
boost: $boost
forward: $forward
hash: $hash
hmac: $hmac
) {
id
deleteScheduledAt
reminderScheduledAt
}
}
`
)
const onSubmit = useItemSubmit(UPSERT_BOUNTY, { item, sub })
const onSubmit = useCallback(
async ({ boost, bounty, crosspost, ...values }) => {
const { data, error } = await upsertBounty({
variables: {
sub: item?.subName || sub?.name,
id: item?.id,
boost: boost ? Number(boost) : undefined,
bounty: bounty ? Number(bounty) : undefined,
...values,
forward: normalizeForwards(values.forward)
}
})
if (error) {
throw new Error({ message: error.toString() })
}
const bountyId = data?.upsertBounty?.id
if (crosspost && bountyId) {
await crossposter(bountyId)
}
if (item) {
await router.push(`/items/${item.id}`)
} else {
const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent')
}
toastUpsertSuccessMessages(toaster, data, 'upsertBounty', !!item, values.text)
}, [upsertBounty, router]
)
const storageKeyPrefix = item ? undefined : 'bounty'
@ -42,6 +108,7 @@ export function BountyForm ({
}}
schema={schema}
requireSession
prepaid
onSubmit={
handleSubmit ||
onSubmit
@ -73,14 +140,14 @@ export function BountyForm ({
hint={
editThreshold
? (
<div className='text-muted fw-bold font-monospace'>
<div className='text-muted fw-bold'>
<Countdown date={editThreshold} />
</div>
)
: null
}
/>
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub} />
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} />
<ItemButtonBar itemId={item?.id} canDelete={false} />
</Form>
)

View File

@ -4,6 +4,6 @@ import Button from 'react-bootstrap/Button'
export default function CancelButton ({ onClick }) {
const router = useRouter()
return (
<Button className='me-3 text-muted nav-link fw-bold' variant='link' onClick={onClick || (() => router.back())}>cancel</Button>
<Button className='me-4 text-muted nav-link fw-bold' variant='link' onClick={onClick || (() => router.back())}>cancel</Button>
)
}

View File

@ -1,140 +0,0 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import classNames from 'classnames'
import ArrowLeft from '@/svgs/arrow-left-line.svg'
import ArrowRight from '@/svgs/arrow-right-line.svg'
import styles from './carousel.module.css'
import { useShowModal } from './modal'
import { Dropdown } from 'react-bootstrap'
function useSwiping ({ moveLeft, moveRight }) {
const [touchStartX, setTouchStartX] = useState(null)
const onTouchStart = useCallback((e) => {
if (e.touches.length === 1) {
setTouchStartX(e.touches[0].clientX)
}
}, [])
const onTouchEnd = useCallback((e) => {
if (touchStartX !== null) {
const touchEndX = e.changedTouches[0].clientX
const diff = touchEndX - touchStartX
if (diff > 50) {
moveLeft()
} else if (diff < -50) {
moveRight()
}
setTouchStartX(null)
}
}, [touchStartX, moveLeft, moveRight])
useEffect(() => {
document.addEventListener('touchstart', onTouchStart)
document.addEventListener('touchend', onTouchEnd)
return () => {
document.removeEventListener('touchstart', onTouchStart)
document.removeEventListener('touchend', onTouchEnd)
}
}, [onTouchStart, onTouchEnd])
}
function useArrowKeys ({ moveLeft, moveRight }) {
const onKeyDown = useCallback((e) => {
if (e.key === 'ArrowLeft') {
moveLeft()
} else if (e.key === 'ArrowRight') {
moveRight()
}
}, [moveLeft, moveRight])
useEffect(() => {
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onKeyDown])
}
function Carousel ({ close, mediaArr, src, setOptions }) {
const [index, setIndex] = useState(mediaArr.findIndex(([key]) => key === src))
const [currentSrc, canGoLeft, canGoRight] = useMemo(() => {
return [mediaArr[index][0], index > 0, index < mediaArr.length - 1]
}, [mediaArr, index])
useEffect(() => {
if (index === -1) return
setOptions({
overflow: <CarouselOverflow {...mediaArr[index][1]} />
})
}, [index, mediaArr, setOptions])
const moveLeft = useCallback(() => {
setIndex(i => Math.max(0, i - 1))
}, [setIndex])
const moveRight = useCallback(() => {
setIndex(i => Math.min(mediaArr.length - 1, i + 1))
}, [setIndex, mediaArr.length])
useSwiping({ moveLeft, moveRight })
useArrowKeys({ moveLeft, moveRight })
return (
<div className={styles.fullScreenContainer} onClick={close}>
<img className={styles.fullScreen} src={currentSrc} />
<div className={styles.fullScreenNavContainer}>
<div
className={classNames(styles.fullScreenNav, !canGoLeft && 'invisible', styles.left)}
onClick={(e) => {
e.stopPropagation()
moveLeft()
}}
>
<ArrowLeft width={34} height={34} />
</div>
<div
className={classNames(styles.fullScreenNav, !canGoRight && 'invisible', styles.right)}
onClick={(e) => {
e.stopPropagation()
moveRight()
}}
>
<ArrowRight width={34} height={34} />
</div>
</div>
</div>
)
}
const CarouselContext = createContext()
function CarouselOverflow ({ originalSrc, rel }) {
return <Dropdown.Item href={originalSrc} rel={rel} target='_blank'>view original</Dropdown.Item>
}
export function CarouselProvider ({ children }) {
const media = useRef(new Map())
const showModal = useShowModal()
const showCarousel = useCallback(({ src }) => {
showModal((close, setOptions) => {
return <Carousel close={close} mediaArr={Array.from(media.current.entries())} src={src} setOptions={setOptions} />
}, {
fullScreen: true,
overflow: <CarouselOverflow {...media.current.get(src)} />
})
}, [showModal])
const addMedia = useCallback(({ src, originalSrc, rel }) => {
media.current.set(src, { src, originalSrc, rel })
}, [])
const removeMedia = useCallback((src) => {
media.current.delete(src)
}, [])
const value = useMemo(() => ({ showCarousel, addMedia, removeMedia }), [showCarousel, addMedia, removeMedia])
return <CarouselContext.Provider value={value}>{children}</CarouselContext.Provider>
}
export function useCarousel () {
return useContext(CarouselContext)
}

View File

@ -1,63 +0,0 @@
div.fullScreenNavContainer {
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
pointer-events: none;
flex-direction: row;
display: flex;
justify-content: space-between;
align-items: center;
}
img.fullScreen {
cursor: zoom-out !important;
max-height: 100%;
max-width: 100vw;
min-width: 0;
min-height: 0;
align-self: center;
justify-self: center;
user-select: none;
}
.fullScreenContainer {
--bs-columns: 1;
--bs-rows: 1;
display: grid;
width: 100%;
height: 100%;
}
div.fullScreenNav:hover > svg {
background-color: rgba(0, 0, 0, .5);
}
div.fullScreenNav {
cursor: pointer;
pointer-events: auto;
width: 72px;
height: 72px;
display: flex;
align-items: center;
}
div.fullScreenNav.left {
justify-content: flex-start;
}
div.fullScreenNav.right {
justify-content: flex-end;
}
div.fullScreenNav > svg {
border-radius: 50%;
backdrop-filter: blur(4px);
background-color: rgba(0, 0, 0, 0.7);
fill: white;
max-height: 34px;
max-width: 34px;
padding: 0.35rem;
margin: .75rem;
}

View File

@ -156,7 +156,7 @@ export function WhenComposedChart ({
data,
lineNames = [], lineAxis = 'left',
areaNames = [], areaAxis = 'left',
barNames = [], barAxis = 'left', barStackId
barNames = [], barAxis = 'left'
}) {
const router = useRouter()
if (!data || data.length === 0) {
@ -189,7 +189,7 @@ export function WhenComposedChart ({
<Tooltip labelFormatter={labelFormatter(when, from, to)} contentStyle={{ color: 'var(--bs-body-color)', backgroundColor: 'var(--bs-body-bg)' }} />
<Legend />
{barNames?.map((v, i) =>
<Bar yAxisId={barAxis} key={v} stackId={barStackId} type='monotone' dataKey={v} name={v} stroke={getColor(i)} fill={getColor(i)} />)}
<Bar yAxisId={barAxis} key={v} type='monotone' dataKey={v} name={v} stroke={getColor(i)} fill={getColor(i)} />)}
{areaNames?.map((v, i) =>
<Area yAxisId={areaAxis} key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={getColor(barNames.length + i)} fill={getColor(barNames.length + i)} />)}
{lineNames?.map((v, i) =>

View File

@ -0,0 +1,187 @@
import { useApolloClient } from '@apollo/client'
import { useMe } from './me'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { datePivot, timeSince } from '@/lib/time'
import { USER_ID, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
import Item, { ItemSkeleton } from './item'
import { RootProvider } from './root'
import Comment from './comment'
const toType = t => ({ ERROR: `${t}_ERROR`, PENDING: `${t}_PENDING` })
export const Types = {
Zap: toType('ZAP'),
Reply: toType('REPLY'),
Bounty: toType('BOUNTY'),
PollVote: toType('POLL_VOTE')
}
const ClientNotificationContext = createContext({ notifications: [], notify: () => {}, unnotify: () => {} })
export function ClientNotificationProvider ({ children }) {
const [notifications, setNotifications] = useState([])
const client = useApolloClient()
const me = useMe()
// anons don't have access to /notifications
// but we'll store client notifications anyway for simplicity's sake
const storageKey = `client-notifications:${me?.id || USER_ID.anon}`
useEffect(() => {
const loaded = loadNotifications(storageKey, client)
setNotifications(loaded)
}, [storageKey])
const notify = useCallback((type, props) => {
const id = crypto.randomUUID()
const sortTime = new Date()
const expiresAt = +datePivot(sortTime, { milliseconds: JIT_INVOICE_TIMEOUT_MS })
const isError = type.endsWith('ERROR')
const n = { __typename: type, id, sortTime: +sortTime, pending: !isError, expiresAt, ...props }
setNotifications(notifications => [n, ...notifications])
saveNotification(storageKey, n)
if (isError) {
client?.writeQuery({
query: HAS_NOTIFICATIONS,
data: {
hasNewNotes: true
}
})
}
return id
}, [storageKey, client])
const unnotify = useCallback((id) => {
setNotifications(notifications => notifications.filter(n => n.id !== id))
removeNotification(storageKey, id)
}, [storageKey])
const value = useMemo(() => ({ notifications, notify, unnotify }), [notifications, notify, unnotify])
return (
<ClientNotificationContext.Provider value={value}>
{children}
</ClientNotificationContext.Provider>
)
}
export function ClientNotifyProvider ({ children, additionalProps }) {
const ctx = useClientNotifications()
const notify = useCallback((type, props) => {
return ctx.notify(type, { ...props, ...additionalProps })
}, [ctx.notify])
const value = useMemo(() => ({ ...ctx, notify }), [ctx, notify])
return (
<ClientNotificationContext.Provider value={value}>
{children}
</ClientNotificationContext.Provider>
)
}
export function useClientNotifications () {
return useContext(ClientNotificationContext)
}
function ClientNotification ({ n, message }) {
if (n.pending) {
const expired = n.expiresAt < +new Date()
if (!expired) return null
n.reason = 'invoice expired'
}
// remove payment hashes due to x-overflow
n.reason = n.reason.replace(/(: )?[a-f0-9]{64}/, '')
return (
<div className='ms-2'>
<small className='fw-bold text-danger'>
{n.reason ? `${message}: ${n.reason}` : message}
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
</small>
{!n.item
? <ItemSkeleton />
: n.item.title
? <Item item={n.item} />
: (
<div className='pb-2'>
<RootProvider root={n.item.root}>
<Comment item={n.item} noReply includeParent noComments clickToContext />
</RootProvider>
</div>
)}
</div>
)
}
export function ClientZap ({ n }) {
const message = `failed to zap ${n.sats || n.amount} sats`
return <ClientNotification n={n} message={message} />
}
export function ClientReply ({ n }) {
const message = 'failed to submit reply'
return <ClientNotification n={n} message={message} />
}
export function ClientBounty ({ n }) {
const message = 'failed to pay bounty'
return <ClientNotification n={n} message={message} />
}
export function ClientPollVote ({ n }) {
const message = 'failed to submit poll vote'
return <ClientNotification n={n} message={message} />
}
function loadNotifications (storageKey, client) {
const stored = window.localStorage.getItem(storageKey)
if (!stored) return []
const filtered = JSON.parse(stored).filter(({ sortTime }) => {
// only keep notifications younger than 24 hours
return new Date(sortTime) >= datePivot(new Date(), { hours: -24 })
})
let hasNewNotes = false
const mapped = filtered.map((n) => {
if (!n.pending) return n
// anything that is still pending when we load the page was interrupted
// so we immediately mark it as failed instead of waiting until it expired
const type = n.__typename.replace('PENDING', 'ERROR')
const reason = 'payment was interrupted'
hasNewNotes = true
return { ...n, __typename: type, pending: false, reason }
})
if (hasNewNotes) {
client?.writeQuery({
query: HAS_NOTIFICATIONS,
data: {
hasNewNotes: true
}
})
}
window.localStorage.setItem(storageKey, JSON.stringify(mapped))
return filtered
}
function saveNotification (storageKey, n) {
const stored = window.localStorage.getItem(storageKey)
if (stored) {
window.localStorage.setItem(storageKey, JSON.stringify([...JSON.parse(stored), n]))
} else {
window.localStorage.setItem(storageKey, JSON.stringify([n]))
}
}
function removeNotification (storageKey, id) {
const stored = window.localStorage.getItem(storageKey)
if (stored) {
window.localStorage.setItem(storageKey, JSON.stringify(JSON.parse(stored).filter(n => n.id !== id)))
}
}

Some files were not shown because too many files have changed in this diff Show More