Compare commits

..

2 Commits

Author SHA1 Message Date
Keyan 266e9a892d
Improve freebies (#1333)
* remove free posts

* deleted and freebie comments are always last
2024-08-26 19:23:07 -05:00
ekzyis cc003a9a3e
Phoenixd send+recv (#1322)
* Add genwallet script

* Add phoenixd as send+recv wallet

* phoenixd passwords are 64 hex chars
2024-08-26 18:20:45 -05:00
19 changed files with 391 additions and 31 deletions

View File

@ -23,7 +23,7 @@ export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio },
// 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 || sub?.allowFreebies) && cost <= baseCost && !!me &&
const freebie = (parentId || bio) && cost <= baseCost && !!me &&
cost > me?.msats && !me?.disableFreebies
return freebie ? BigInt(0) : BigInt(cost)

View File

@ -23,19 +23,19 @@ import performPaidAction from '../paidAction'
function commentsOrderByClause (me, models, sort) {
if (sort === 'recent') {
return 'ORDER BY "Item".created_at DESC, "Item".id DESC'
return 'ORDER BY ("Item"."deletedAt" IS NULL) DESC, ("Item".cost > 0 OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0) DESC, "Item".created_at DESC, "Item".id DESC'
}
if (me && sort === 'hot') {
return `ORDER BY COALESCE(
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, COALESCE(
personal_hot_score,
${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3)) DESC NULLS LAST,
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
} else {
if (sort === 'top') {
return `ORDER BY ${orderByNumerator(models, 0)} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator(models, 0)} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
} else {
return `ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
}
}
}
@ -1290,11 +1290,7 @@ export const updateItem = async (parent, { sub: subName, forward, ...item }, { m
const differentSub = subName && old.subName !== subName
if (differentSub) {
const sub = await models.sub.findUnique({ where: { name: subName } })
if (old.cost === 0) {
if (!sub.allowFreebies) {
throw new GraphQLError(`~${subName} does not allow freebies`, { extensions: { code: 'BAD_INPUT' } })
}
} else if (sub.baseCost > old.sub.baseCost) {
if (sub.baseCost > old.sub.baseCost) {
throw new GraphQLError('cannot change to a more expensive sub', { extensions: { code: 'BAD_INPUT' } })
}
}

View File

@ -16,7 +16,7 @@ export default gql`
extend type Mutation {
upsertSub(oldName: String, name: String!, desc: String, baseCost: Int!,
postTypes: [String!]!, allowFreebies: Boolean!,
postTypes: [String!]!,
billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
paySub(name: String!): SubPaidAction!
@ -24,7 +24,7 @@ export default gql`
toggleSubSubscription(name: String!): Boolean!
transferTerritory(subName: String!, userName: String!): Sub
unarchiveTerritory(name: String!, desc: String, baseCost: Int!,
postTypes: [String!]!, allowFreebies: Boolean!,
postTypes: [String!]!,
billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
}

View File

@ -14,7 +14,7 @@ import { SubmitButton } from './form'
const FeeButtonContext = createContext()
export function postCommentBaseLineItems ({ baseCost = 1, comment = false, allowFreebies = true, me }) {
export function postCommentBaseLineItems ({ baseCost = 1, comment = false, me }) {
const anonCharge = me
? {}
: {
@ -29,7 +29,7 @@ export function postCommentBaseLineItems ({ baseCost = 1, comment = false, allow
term: baseCost,
label: `${comment ? 'comment' : 'post'} cost`,
modifier: (cost) => cost + baseCost,
allowFreebies
allowFreebies: comment
},
...anonCharge
}

View File

@ -149,7 +149,7 @@ export function PostForm ({ type, sub, children }) {
return (
<FeeButtonProvider
baseLineItems={sub ? postCommentBaseLineItems({ baseCost: sub.baseCost, allowFreebies: sub.allowFreebies, me: !!me }) : undefined}
baseLineItems={sub ? postCommentBaseLineItems({ baseCost: sub.baseCost, me: !!me }) : undefined}
useRemoteLineItems={postCommentUseRemoteLineItems({ me: !!me })}
>
<FormType sub={sub}>{children}</FormType>

View File

@ -91,7 +91,6 @@ export default function TerritoryForm ({ sub }) {
desc: sub?.desc || '',
baseCost: sub?.baseCost || 10,
postTypes: sub?.postTypes || POST_TYPES,
allowFreebies: typeof sub?.allowFreebies === 'undefined' ? true : sub?.allowFreebies,
billingType: sub?.billingType || 'MONTHLY',
billingAutoRenew: sub?.billingAutoRenew || false,
moderated: sub?.moderated || false,
@ -133,15 +132,9 @@ export default function TerritoryForm ({ sub }) {
label='post cost'
name='baseCost'
type='number'
groupClassName='mb-2'
required
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<Checkbox
label='allow free posts'
name='allowFreebies'
groupClassName='ms-1'
/>
<CheckboxGroup label='post types' name='postTypes'>
<Row>
<Col xs={4} sm='auto'>

View File

@ -223,10 +223,10 @@ export const UPDATE_COMMENT = gql`
export const UPSERT_SUB = gql`
${PAID_ACTION}
mutation upsertSub($oldName: String, $name: String!, $desc: String, $baseCost: Int!,
$postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!,
$postTypes: [String!]!, $billingType: String!,
$billingAutoRenew: Boolean!, $moderated: Boolean!, $nsfw: Boolean!) {
upsertSub(oldName: $oldName, name: $name, desc: $desc, baseCost: $baseCost,
postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType,
postTypes: $postTypes, billingType: $billingType,
billingAutoRenew: $billingAutoRenew, moderated: $moderated, nsfw: $nsfw) {
result {
name
@ -238,10 +238,10 @@ export const UPSERT_SUB = gql`
export const UNARCHIVE_TERRITORY = gql`
${PAID_ACTION}
mutation unarchiveTerritory($name: String!, $desc: String, $baseCost: Int!,
$postTypes: [String!]!, $allowFreebies: Boolean!, $billingType: String!,
$postTypes: [String!]!, $billingType: String!,
$billingAutoRenew: Boolean!, $moderated: Boolean!, $nsfw: Boolean!) {
unarchiveTerritory(name: $name, desc: $desc, baseCost: $baseCost,
postTypes: $postTypes, allowFreebies: $allowFreebies, billingType: $billingType,
postTypes: $postTypes, billingType: $billingType,
billingAutoRenew: $billingAutoRenew, moderated: $moderated, nsfw: $nsfw) {
result {
name

View File

@ -7,7 +7,6 @@ export const SUB_FIELDS = gql`
name
createdAt
postTypes
allowFreebies
rankingType
billingType
billingCost

View File

@ -137,6 +137,10 @@ export const WALLET = gql`
... on WalletNwc {
nwcUrlRecv
}
... on WalletPhoenixd {
url
secondaryPassword
}
}
}
}
@ -173,6 +177,10 @@ export const WALLET_BY_TYPE = gql`
... on WalletNwc {
nwcUrlRecv
}
... on WalletPhoenixd {
url
secondaryPassword
}
}
}
}

View File

@ -762,6 +762,26 @@ export const lncSchema = object({
.required('required')
})
export const phoenixdSchema = object().shape({
url: string().url().required('required').trim(),
primaryPassword: string().length(64).hex()
.when(['secondaryPassword'], ([secondary], schema) => {
if (!secondary) return schema.required('required if secondary password not set')
return schema.test({
test: primary => secondary !== primary,
message: 'primary password cannot be the same as secondary password'
})
}),
secondaryPassword: string().length(64).hex()
.when(['primaryPassword'], ([primary], schema) => {
if (!primary) return schema.required('required if primary password not set')
return schema.test({
test: secondary => primary !== secondary,
message: 'secondary password cannot be the same as primary password'
})
})
}, ['primaryPassword', 'secondaryPassword'])
export const bioSchema = object({
bio: string().required('required').trim()
})

View File

@ -257,9 +257,9 @@ export default function Settings ({ ssrData }) {
label={
<div className='d-flex align-items-center'>disable freebies
<Info>
<p>Some posts and comments can be created without paying. However, that content has limited visibility.</p>
<p>Some comments can be created without paying. However, those comments have limited visibility.</p>
<p>If you disable freebies, you will always pay for your posts and comments and get standard visibility.</p>
<p>If you disable freebies, you will always pay for your comments and get standard visibility.</p>
<p>If you attach a sending wallet, we disable freebies for you unless you have checked/unchecked this value already.</p>
</Info>

View File

@ -0,0 +1,24 @@
-- AlterEnum
ALTER TYPE "WalletType" ADD VALUE 'PHOENIXD';
-- CreateTable
CREATE TABLE "WalletPhoenixd" (
"id" SERIAL NOT NULL,
"walletId" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"url" TEXT NOT NULL,
"secondaryPassword" TEXT NOT NULL,
CONSTRAINT "WalletPhoenixd_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WalletPhoenixd_walletId_key" ON "WalletPhoenixd"("walletId");
-- AddForeignKey
ALTER TABLE "WalletPhoenixd" ADD CONSTRAINT "WalletPhoenixd_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TRIGGER wallet_phoenixd_as_jsonb
AFTER INSERT OR UPDATE ON "WalletPhoenixd"
FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb();

View File

@ -171,6 +171,7 @@ enum WalletType {
CLN
LNBITS
NWC
PHOENIXD
}
model Wallet {
@ -196,6 +197,7 @@ model Wallet {
walletCLN WalletCLN?
walletLNbits WalletLNbits?
walletNWC WalletNWC?
walletPhoenixd WalletPhoenixd?
withdrawals Withdrawl[]
InvoiceForward InvoiceForward[]
@ -264,6 +266,16 @@ model WalletNWC {
nwcUrlRecv String
}
model WalletPhoenixd {
id Int @id @default(autoincrement())
walletId Int @unique
wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
url String
secondaryPassword String
}
model Mute {
muterId Int
mutedId Int

184
scripts/genwallet.sh Normal file
View File

@ -0,0 +1,184 @@
#!/usr/bin/env bash
cat <<EOF
.__ .__ __
____ ____ ______ _ _______ | | | | _____/ |_
/ ___\_/ __ \ / \ \/ \/ /\__ \ | | | | _/ __ \ __\\
/ /_/ > ___/| | \ / / __ \| |_| |_\ ___/| |
\___ / \___ >___| /\/\_/ (____ /____/____/\___ >__|
/_____/ \/ \/ \/ \/
EOF
error () {
echo -n "error: $1"
exit 1
}
wallet=$1
[ -z $wallet ] && read -p "Enter wallet name: " wallet
[ -z $wallet ] && error "name required"
# default is wallet in UPPERCASE
walletType="${wallet^^}"
read -p "Enter walletType (default $walletType): " _walletType
if [ ! -z $_walletType ]; then
walletType=$_walletType
fi
# default is wallet capitalized with "wallet" prefix
walletField="wallet${wallet^}"
read -p "Enter walletField (default $walletField): " _walletField
if [ ! -z $_walletField ]; then
walletField=$_walletField
fi
# exit on first failed command
set -e
todo() {
echo "// $wallet::TODO"
}
# create folder and index.js
mkdir -p wallets/$wallet
cat > wallets/$wallet/index.js <<EOF
$(todo)
// create validation schema for wallet and import here
// import { ${wallet}Schema } from '@/lib/validate'
export const name = '$wallet'
$(todo)
// configure wallet fields
export const fields = []
$(todo)
// configure wallet card
export const card = {
title: '$wallet',
subtitle: '',
badges: []
}
$(todo)
// set validation schema
export const fieldValidation = null // ${wallet}Schema
export const walletType = '$walletType'
export const walletField = '$walletField'
EOF
# create client.js
cat > wallets/$wallet/client.js <<EOF
export * from 'wallets/$wallet'
export async function testSendPayment (config, { logger }) {
$(todo)
}
export async function sendPayment (bolt11, config) {
$(todo)
}
EOF
# create server.js
cat > wallets/$wallet/server.js <<EOF
export * from 'wallets/$wallet'
export async function testCreateInvoice (config) {
$(todo)
}
export async function createInvoice (
{ msats, description, descriptionHash, expiry },
config
) {
$(todo)
}
EOF
# add TODOs where manual update is needed
fragments=fragments/wallet.js
i=0
grep -n "// XXX \[WALLET\]" $fragments | while read -r match;
do
lineno=$(echo $match | cut -d':' -f1)
sed -i "$((lineno+i))i $(todo)" $fragments
i=$((i+1))
done
client=wallets/client.js
lineno=$(grep -n "export default" $client | cut -d':' -f1)
sed -i "${lineno}i $(todo)" $client
server=wallets/server.js
lineno=$(grep -n "export default" $server | cut -d':' -f1)
sed -i "${lineno}i $(todo)" $server
# need to disable exit on failure since we run grep to check its exit code
set +e
# check if prisma/schema.prisma needs patch
schema=prisma/schema.prisma
grep --quiet "$walletField" $schema
if [ $? -eq 1 ]; then
tablename=${walletField^}
# find line to insert walletField in wallet model
lineno=$(grep -n "model Wallet {" $schema | cut -d':' -f1)
offset=$(tail -n +$lineno $schema | grep -nm 1 "}" | cut -d':' -f1)
offset=$(tail -n +$lineno $schema | head -n $offset | grep -nE "wallet[[:alpha:]]+\s+ Wallet[[:alpha:]]" | cut -d':' -f1 | tail -n1)
sed -i "$((lineno+offset))i\ \ $walletField $tablename?" $schema
# find line to insert model for wallet
lineno=$(grep -nE "model Wallet[[:alpha:]]+ {" $schema | cut -d':' -f1 | tail -n1)
offset=$(tail -n +$((lineno+1)) $schema | grep -nm 1 "{" | cut -d':' -f1)
i=$((lineno+offset))
sed -i "${i}i $(todo)" $schema
sed -i "$((i+1))i model Wallet${wallet^} {\n" $schema
sed -i "$((i+2))i\ \ id Int @id @default(autoincrement())" $schema
sed -i "$((i+3))i\ \ walletId Int @unique" $schema
sed -i "$((i+4))i\ \ wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)" $schema
sed -i "$((i+5))i\ \ createdAt DateTime @default(now()) @map(\"created_at\")" $schema
sed -i "$((i+6))i\ \ updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\")" $schema
sed -i "$((i+7))i }" $schema
# find line to insert wallet type
lineno=$(grep -nE "enum WalletType {" $schema | cut -d':' -f1)
offset=$(tail -n +$lineno $schema | grep -nm 1 "}" | cut -d':' -f1)
i=$((lineno+offset-1))
sed -i "${i} i\ \ ${walletType}" $schema
# create migration file with TODOs
migrationDir="prisma/migrations/$(date +%Y%m%d%H%M%S_$wallet)"
mkdir -p $migrationDir
cat > $migrationDir/migration.sql <<EOF
-- AlterEnum
ALTER TYPE "WalletType" ADD VALUE '${walletType}';
-- CreateTable
CREATE TABLE "$tablename" (
"id" SERIAL NOT NULL,
"walletId" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- $wallet::TODO
CONSTRAINT "${tablename}_pkey" PRIMARY KEY ("int")
);
-- CreateIndex
CREATE UNIQUE INDEX "${tablename}_walletId_key" ON "$tablename"("walletId");
-- AddForeignKey
ALTER TABLE "$tablename" ADD CONSTRAINT "${tablename}_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TRIGGER wallet_${wallet}_as_jsonb
AFTER INSERT OR UPDATE ON "$tablename"
FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb();
EOF
fi

View File

@ -6,5 +6,6 @@ import * as cln from 'wallets/cln/client'
import * as lnd from 'wallets/lnd/client'
import * as webln from 'wallets/webln/client'
import * as blink from 'wallets/blink/client'
import * as phoenixd from 'wallets/phoenixd/client'
export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln, blink]
export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln, blink, phoenixd]

View File

@ -0,0 +1,39 @@
export * from 'wallets/phoenixd'
export async function testSendPayment (config, { logger }) {
// TODO:
// Not sure which endpoint to call to test primary password
// see https://phoenix.acinq.co/server/api
// Maybe just wait until test payments with HODL invoices?
}
export async function sendPayment (bolt11, { url, primaryPassword }) {
// https://phoenix.acinq.co/server/api#pay-bolt11-invoice
const path = '/payinvoice'
const headers = new Headers()
headers.set('Authorization', 'Basic ' + Buffer.from(':' + primaryPassword).toString('base64'))
headers.set('Content-type', 'application/x-www-form-urlencoded')
const body = new URLSearchParams()
body.append('invoice', bolt11)
const res = await fetch(url + path, {
method: 'POST',
headers,
body
})
if (!res.ok) {
const error = await res.text()
throw new Error(error)
}
const payment = await res.json()
const preimage = payment.paymentPreimage
if (!preimage) {
throw new Error(payment.reason)
}
return payment.paymentPreimage
}

45
wallets/phoenixd/index.js Normal file
View File

@ -0,0 +1,45 @@
import { phoenixdSchema } from '@/lib/validate'
export const name = 'phoenixd'
// configure wallet fields
export const fields = [
{
name: 'url',
label: 'url',
type: 'text'
},
{
name: 'primaryPassword',
label: 'primary password',
type: 'password',
optional: 'for sending',
help: 'You can find the primary password as `http-password` in your phoenixd configuration file as mentioned [here](https://phoenix.acinq.co/server/api#security).',
clientOnly: true,
editable: false
},
{
name: 'secondaryPassword',
label: 'secondary password',
type: 'password',
optional: 'for receiving',
help: 'You can find the secondary password as `http-password-limited-access` in your phoenixd configuration file as mentioned [here](https://phoenix.acinq.co/server/api#security).',
serverOnly: true,
editable: false
}
]
// configure wallet card
export const card = {
title: 'phoenixd',
subtitle: 'use [phoenixd](https://phoenix.acinq.co/server) for payments',
badges: ['send & receive']
}
// phoenixd::TODO
// set validation schema
export const fieldValidation = phoenixdSchema
export const walletType = 'PHOENIXD'
export const walletField = 'walletPhoenixd'

View File

@ -0,0 +1,38 @@
import { msatsToSats } from '@/lib/format'
export * from 'wallets/phoenixd'
export async function testCreateInvoice ({ url, secondaryPassword }) {
return await createInvoice(
{ msats: 1000, description: 'SN test invoice', expiry: 1 },
{ url, secondaryPassword })
}
export async function createInvoice (
{ msats, description, descriptionHash, expiry },
{ url, secondaryPassword }
) {
// https://phoenix.acinq.co/server/api#create-bolt11-invoice
const path = '/createinvoice'
const headers = new Headers()
headers.set('Authorization', 'Basic ' + Buffer.from(':' + secondaryPassword).toString('base64'))
headers.set('Content-type', 'application/x-www-form-urlencoded')
const body = new URLSearchParams()
body.append('description', description)
body.append('amountSat', msatsToSats(msats))
const res = await fetch(url + path, {
method: 'POST',
headers,
body
})
if (!res.ok) {
const error = await res.text()
throw new Error(error)
}
const payment = await res.json()
return payment.serialized
}

View File

@ -3,12 +3,13 @@ import * as cln from 'wallets/cln/server'
import * as lnAddr from 'wallets/lightning-address/server'
import * as lnbits from 'wallets/lnbits/server'
import * as nwc from 'wallets/nwc/server'
import * as phoenixd from 'wallets/phoenixd/server'
import { addWalletLog } from '@/api/resolvers/wallet'
import walletDefs from 'wallets/server'
import { parsePaymentRequest } from 'ln-service'
import { toPositiveNumber } from '@/lib/validate'
import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
export default [lnd, cln, lnAddr, lnbits, nwc]
export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd]
const MAX_PENDING_INVOICES_PER_WALLET = 25