Merge pull request #79 from MAGICGrants/funding-required-endpoint

Funding required endpoint
This commit is contained in:
Artur
2024-09-20 20:00:59 -03:00
committed by GitHub
14 changed files with 480 additions and 79 deletions

View File

@@ -7,9 +7,9 @@ services:
expose:
- '49392'
environment:
BTCPAY_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=btcpayserver;Database=btcpayserver${NBITCOIN_NETWORK:-regtest}
BTCPAY_EXPLORERPOSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=btcpayserver;MaxPoolSize=80;Database=nbxplorer${NBITCOIN_NETWORK:-regtest}
BTCPAY_NETWORK: ${NBITCOIN_NETWORK:-regtest}
BTCPAY_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=btcpayserver;Database=btcpayserver${NBITCOIN_NETWORK:-mainnet}
BTCPAY_EXPLORERPOSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=btcpayserver;MaxPoolSize=80;Database=nbxplorer${NBITCOIN_NETWORK:-mainnet}
BTCPAY_NETWORK: ${NBITCOIN_NETWORK:-mainnet}
BTCPAY_BIND: 0.0.0.0:49392
BTCPAY_ROOTPATH: ${BTCPAY_ROOTPATH:-/}
BTCPAY_SSHCONNECTION: 'root@host.docker.internal'
@@ -57,11 +57,11 @@ services:
expose:
- '32838'
environment:
NBXPLORER_NETWORK: ${NBITCOIN_NETWORK:-regtest}
NBXPLORER_NETWORK: ${NBITCOIN_NETWORK:-mainnet}
NBXPLORER_BIND: 0.0.0.0:32838
NBXPLORER_TRIMEVENTS: 10000
NBXPLORER_SIGNALFILESDIR: /datadir
NBXPLORER_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=nbxplorer;MaxPoolSize=20;Database=nbxplorer${NBITCOIN_NETWORK:-regtest}
NBXPLORER_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=nbxplorer;MaxPoolSize=20;Database=nbxplorer${NBITCOIN_NETWORK:-mainnet}
NBXPLORER_AUTOMIGRATE: 1
NBXPLORER_NOMIGRATEEVTS: 1
NBXPLORER_DELETEAFTERMIGRATION: 1

View File

@@ -3,6 +3,7 @@ fund: monero
title: 'ETH<>XMR Atomic Swap Continued Development'
summary: 'A trustless way to exchange Monero and Ethereum.'
nym: 'noot'
date: '2022-09-01'
coverImage: '/img/project/Ethereum_logo.png'
website: 'https://github.com/AthanorLabs/atomic-swap'
personalWebsite: 'https://github.com/noot'

View File

@@ -3,6 +3,7 @@ fund: monero
title: 'Ring Signature Resiliency to AI Analysis'
summary: "A test of machine learning attacks on Monero's untraceability."
nym: 'ACK-J'
date: '2022-03-01'
coverImage: '/img/project/ring_sig.png'
website: 'https://magicgrants.org/Monero-Tracing-Research/'
personalWebsite: 'https://github.com/ACK-J'

86
package-lock.json generated
View File

@@ -2760,9 +2760,9 @@
}
},
"node_modules/@next/env": {
"version": "14.2.9",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.9.tgz",
"integrity": "sha512-hnDAoDPMii31V0ivibI8p6b023jOF1XblWTVjsDUoZKwnZlaBtJFZKDwFqi22R8r9i6W08dThUWU7Bsh2Rg8Ww==",
"version": "14.2.12",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.12.tgz",
"integrity": "sha512-3fP29GIetdwVIfIRyLKM7KrvJaqepv+6pVodEbx0P5CaMLYBtx+7eEg8JYO5L9sveJO87z9eCReceZLi0hxO1Q==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -2776,9 +2776,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.9",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.9.tgz",
"integrity": "sha512-/kfQifl3uLYi3DlwFlzCkgxe6fprJNLzzTUFknq3M5wGYicDIbdGlxUl6oHpVLJpBB/CBY3Y//gO6alz/K4NWA==",
"version": "14.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.12.tgz",
"integrity": "sha512-crHJ9UoinXeFbHYNok6VZqjKnd8rTd7K3Z2zpyzF1ch7vVNKmhjv/V7EHxep3ILoN8JB9AdRn/EtVVyG9AkCXw==",
"cpu": [
"arm64"
],
@@ -2792,9 +2792,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.9",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.9.tgz",
"integrity": "sha512-tK/RyhCmOCiXQ9IVdFrBbZOf4/1+0RSuJkebXU2uMEsusS51TjIJO4l8ZmEijH9gZa0pJClvmApRHi7JuBqsRw==",
"version": "14.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.12.tgz",
"integrity": "sha512-JbEaGbWq18BuNBO+lCtKfxl563Uw9oy2TodnN2ioX00u7V1uzrsSUcg3Ep9ce+P0Z9es+JmsvL2/rLphz+Frcw==",
"cpu": [
"x64"
],
@@ -2808,9 +2808,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.9",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.9.tgz",
"integrity": "sha512-tS5eqwsp2nO7mzywRUuFYmefNZsUKM/mTG3exK2jIHv9TEVklE1SByB1KMhFkqlit1PxS9YK1tV8BOV90Wpbrw==",
"version": "14.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.12.tgz",
"integrity": "sha512-qBy7OiXOqZrdp88QEl2H4fWalMGnSCrr1agT/AVDndlyw2YJQA89f3ttR/AkEIP9EkBXXeGl6cC72/EZT5r6rw==",
"cpu": [
"arm64"
],
@@ -2824,9 +2824,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.9",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.9.tgz",
"integrity": "sha512-8svpeTFNAMTUMKQbEzE8qRAwl9o7mNBv7LR1bmSkQvo1oy4WrNyZbhWsldOiKrc4mZ5dfQkGYsI9T75mIFMfeA==",
"version": "14.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.12.tgz",
"integrity": "sha512-EfD9L7o9biaQxjwP1uWXnk3vYZi64NVcKUN83hpVkKocB7ogJfyH2r7o1pPnMtir6gHZiGCeHKagJ0yrNSLNHw==",
"cpu": [
"arm64"
],
@@ -2840,9 +2840,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.9",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.9.tgz",
"integrity": "sha512-0HNulLWpKTB7H5BhHCkEhcRAnWUHeAYCftrrGw3QC18+ZywTdAoPv/zEqKy/0adqt+ks4JDdlgSQ1lNKOKjo0A==",
"version": "14.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.12.tgz",
"integrity": "sha512-iQ+n2pxklJew9IpE47hE/VgjmljlHqtcD5UhZVeHICTPbLyrgPehaKf2wLRNjYH75udroBNCgrSSVSVpAbNoYw==",
"cpu": [
"x64"
],
@@ -2856,9 +2856,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.9",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.9.tgz",
"integrity": "sha512-hhVFViPHLAVUJRNtwwm609p9ozWajOmRvzOZzzKXgiVGwx/CALxlMUeh+M+e0Zj6orENhWLZeilOPHpptuENsA==",
"version": "14.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.12.tgz",
"integrity": "sha512-rFkUkNwcQ0ODn7cxvcVdpHlcOpYxMeyMfkJuzaT74xjAa5v4fxP4xDk5OoYmPi8QNLDs3UgZPMSBmpBuv9zKWA==",
"cpu": [
"x64"
],
@@ -2872,9 +2872,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.9",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.9.tgz",
"integrity": "sha512-p/v6XlOdrk06xfN9z4evLNBqftVQUWiyduQczCwSj7hNh8fWTbzdVxsEiNOcajMXJbQiaX/ZzZdFgKVmmJnnGQ==",
"version": "14.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.12.tgz",
"integrity": "sha512-PQFYUvwtHs/u0K85SG4sAdDXYIPXpETf9mcEjWc0R4JmjgMKSDwIU/qfZdavtP6MPNiMjuKGXHCtyhR/M5zo8g==",
"cpu": [
"arm64"
],
@@ -2888,9 +2888,9 @@
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.9",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.9.tgz",
"integrity": "sha512-IcW9dynWDjMK/0M05E3zopbRen7v0/yEaMZbHFOSS1J/w+8YG3jKywOGZWNp/eCUVtUUXs0PW+7Lpz8uLu+KQA==",
"version": "14.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.12.tgz",
"integrity": "sha512-FAj2hMlcbeCV546eU2tEv41dcJb4NeqFlSXU/xL/0ehXywHnNpaYajOUvn3P8wru5WyQe6cTZ8fvckj/2XN4Vw==",
"cpu": [
"ia32"
],
@@ -2904,9 +2904,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.9",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.9.tgz",
"integrity": "sha512-gcbpoXyWZdVOBgNa5BRzynrL5UR1nb2ZT38yKgnphYU9UHjeecnylMHntrQiMg/QtONDcJPFC/PmsS47xIRYoA==",
"version": "14.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.12.tgz",
"integrity": "sha512-yu8QvV53sBzoIVRHsxCHqeuS8jYq6Lrmdh0briivuh+Brsp6xjg80MAozUsBTAV9KNmY08KlX0KYTWz1lbPzEg==",
"cpu": [
"x64"
],
@@ -9227,12 +9227,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "14.2.9",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.9.tgz",
"integrity": "sha512-3CzBNo6BuJnRjcQvRw+irnU1WiuJNZEp+dkzkt91y4jeIDN/Emg95F+takSYiLpJ/HkxClVQRyqiTwYce5IVqw==",
"version": "14.2.12",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.12.tgz",
"integrity": "sha512-cDOtUSIeoOvt1skKNihdExWMTybx3exnvbFbb9ecZDIxlvIbREQzt9A5Km3Zn3PfU+IFjyYGsHS+lN9VInAGKA==",
"license": "MIT",
"dependencies": {
"@next/env": "14.2.9",
"@next/env": "14.2.12",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
@@ -9247,15 +9247,15 @@
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.9",
"@next/swc-darwin-x64": "14.2.9",
"@next/swc-linux-arm64-gnu": "14.2.9",
"@next/swc-linux-arm64-musl": "14.2.9",
"@next/swc-linux-x64-gnu": "14.2.9",
"@next/swc-linux-x64-musl": "14.2.9",
"@next/swc-win32-arm64-msvc": "14.2.9",
"@next/swc-win32-ia32-msvc": "14.2.9",
"@next/swc-win32-x64-msvc": "14.2.9"
"@next/swc-darwin-arm64": "14.2.12",
"@next/swc-darwin-x64": "14.2.12",
"@next/swc-linux-arm64-gnu": "14.2.12",
"@next/swc-linux-arm64-musl": "14.2.12",
"@next/swc-linux-x64-gnu": "14.2.12",
"@next/swc-linux-x64-musl": "14.2.12",
"@next/swc-win32-arm64-msvc": "14.2.12",
"@next/swc-win32-ia32-msvc": "14.2.12",
"@next/swc-win32-x64-msvc": "14.2.12"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",

View File

@@ -3,9 +3,14 @@ import getRawBody from 'raw-body'
import crypto from 'crypto'
import dayjs from 'dayjs'
import { DonationMetadata } from '../../../server/types'
import {
BtcPayGetRatesRes,
BtcPayGetPaymentMethodsRes,
DonationMetadata,
} from '../../../server/types'
import { btcpayApi as _btcpayApi, btcpayApi, prisma } from '../../../server/services'
import { env } from '../../../env.mjs'
import axios from 'axios'
export const config = {
api: {
@@ -13,7 +18,7 @@ export const config = {
},
}
type BtcpayBody = {
type BtcpayBody = Record<string, any> & {
deliveryId: string
webhookId: string
originalDeliveryId: string
@@ -25,12 +30,6 @@ type BtcpayBody = {
metadata: DonationMetadata
}
type BtcpayPaymentMethodsResponse = {
rate: string
amount: string
cryptoCode: string
}[]
async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
res.setHeader('Allow', ['POST'])
@@ -59,30 +58,71 @@ async function handleBtcpayWebhook(req: NextApiRequest, res: NextApiResponse) {
return
}
if (body.type === 'InvoiceSettled') {
const { data: paymentMethods } = await btcpayApi.get<BtcpayPaymentMethodsResponse>(
`/invoices/${body.invoiceId}/payment-methods`
if (body.type === 'InvoicePaymentSettled') {
// Handle payments to funding required API invoices ONLY
if (body.metadata.staticGeneratedForApi === 'false') {
return res.status(200).json({ success: true })
}
const cryptoCode = body.paymentMethod === 'BTC-OnChain' ? 'BTC' : 'XMR'
const { data: rates } = await btcpayApi.get<BtcPayGetRatesRes>(
`/rates?currencyPair=${cryptoCode}_USD`
)
const cryptoAmount = Number(paymentMethods[0].amount)
const fiatAmount = Number(paymentMethods[0].amount) * Number(paymentMethods[0].rate)
const cryptoRate = Number(rates[0].rate)
const cryptoAmount = Number(body.payment.value)
await prisma.donation.create({
data: {
userId: body.metadata.userId,
userId: null,
btcPayInvoiceId: body.invoiceId,
projectName: body.metadata.projectName,
projectSlug: body.metadata.projectSlug,
fundSlug: body.metadata.fundSlug,
cryptoCode: paymentMethods[0].cryptoCode,
cryptoCode,
cryptoAmount,
fiatAmount: Number(fiatAmount.toFixed(2)),
membershipExpiresAt:
body.metadata.isMembership === 'true' ? dayjs().add(1, 'year').toDate() : null,
fiatAmount: Number((cryptoAmount * cryptoRate).toFixed(2)),
},
})
}
if (body.type === 'InvoiceSettled') {
// If this is a funding required API invoice, let InvoiceReceivedPayment handle it instead
if (body.metadata.staticGeneratedForApi === 'true') {
return res.status(200).json({ success: true })
}
const { data: paymentMethods } = await btcpayApi.get<BtcPayGetPaymentMethodsRes>(
`/invoices/${body.invoiceId}/payment-methods`
)
await Promise.all(
paymentMethods.map(async (paymentMethod) => {
const cryptoAmount = Number(paymentMethod.amount)
if (!cryptoAmount) return
const fiatAmount = Number(paymentMethod.amount) * Number(paymentMethod.rate)
await prisma.donation.create({
data: {
userId: body.metadata.userId,
btcPayInvoiceId: body.invoiceId,
projectName: body.metadata.projectName,
projectSlug: body.metadata.projectSlug,
fundSlug: body.metadata.fundSlug,
cryptoCode: paymentMethod.cryptoCode,
cryptoAmount,
fiatAmount: Number(fiatAmount.toFixed(2)),
membershipExpiresAt:
body.metadata.isMembership === 'true' ? dayjs().add(1, 'year').toDate() : null,
},
})
})
)
}
res.status(200).json({ success: true })
}

View File

@@ -0,0 +1,263 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { FundSlug } from '@prisma/client'
import { z } from 'zod'
import dayjs from 'dayjs'
import path from 'path'
import { getProjects } from '../../utils/md'
import { env } from '../../env.mjs'
import { btcpayApi, prisma } from '../../server/services'
import { CURRENCY } from '../../config'
import {
BtcPayCreateInvoiceRes,
BtcPayGetPaymentMethodsRes,
BtcPayGetRatesRes,
DonationMetadata,
} from '../../server/types'
import { funds, fundSlugs } from '../../utils/funds'
const ASSETS = ['BTC', 'XMR', 'USD'] as const
type Asset = (typeof ASSETS)[number]
type ResponseBody = {
title: string
fund: FundSlug
date: string
author: string
url: string
is_funded: boolean
raised_amount_percent: number
contributions: number
target_amount_btc: number
target_amount_xmr: number
target_amount_usd: number
remaining_amount_btc: number
remaining_amount_xmr: number
remaining_amount_usd: number
address_btc: string | null
address_xmr: string | null
}[]
type ResponseBodySpecificAsset = {
title: string
fund: FundSlug
date: string
author: string
url: string
is_funded: boolean
raised_amount_percent: number
contributions: number
asset: Asset
target_amount: number
remaining_amount: number
address: string | null
}[]
// The cache key should be: fund-asset-project_status
const cachedResponses: Record<
string,
{ data: ResponseBody | ResponseBodySpecificAsset; expiresAt: Date } | undefined
> = {}
const querySchema = z.object({
fund: z.enum(fundSlugs).optional(),
asset: z.enum(ASSETS).optional(),
project_status: z.enum(['FUNDED', 'NOT_FUNDED', 'ANY']).default('NOT_FUNDED'),
})
async function handle(
req: NextApiRequest,
res: NextApiResponse<ResponseBody | ResponseBodySpecificAsset>
) {
if (req.method !== 'GET') {
res.setHeader('Allow', ['GET'])
return res.status(405).end(`Method ${req.method} Not Allowed`)
}
const query = await querySchema.parseAsync(req.query)
// Get response from cache
const cacheKey = `${query.fund}-${query.asset}-${query.project_status}`
const cachedResponse = cachedResponses[cacheKey]
if (cachedResponse && cachedResponse.expiresAt > new Date()) {
return res.send(cachedResponse.data)
}
const projects = (await getProjects(query.fund)).filter((project) =>
query.project_status === 'ANY' || query.project_status === 'FUNDED'
? project.isFunded
: !project.isFunded
)
const rates: Record<string, number | undefined> = {}
// Get exchange rates if target asset is not USD (or if there is no target asset)
if (query.asset !== 'USD') {
const assetsWithoutUsd = ASSETS.filter((asset) => asset !== 'USD')
const params = assetsWithoutUsd.map((asset) => `currencyPair=${asset}_USD`).join('&')
const { data: _rates } = await btcpayApi.get<BtcPayGetRatesRes>(`/rates?${params}`)
_rates.forEach((rate) => {
const asset = rate.currencyPair.split('_')[0] as string
rates[asset] = Number(rate.rate)
})
}
let responseBody: ResponseBody | ResponseBodySpecificAsset = await Promise.all(
projects.map(async (project): Promise<ResponseBody[0]> => {
let bitcoinAddress: string | null = null
let moneroAddress: string | null = null
if (!project.isFunded) {
const existingAddresses = await prisma.projectAddresses.findFirst({
where: { projectSlug: project.slug, fundSlug: project.fund },
})
// Create invoice if there's no existing address
if (!existingAddresses) {
const metadata: DonationMetadata = {
userId: null,
donorName: null,
donorEmail: null,
projectSlug: project.slug,
projectName: project.title,
fundSlug: project.fund as FundSlug,
isMembership: 'false',
isSubscription: 'false',
isTaxDeductible: 'false',
staticGeneratedForApi: 'true',
}
const { data: invoice } = await btcpayApi.post<BtcPayCreateInvoiceRes>('/invoices', {
currency: CURRENCY,
metadata,
})
const paymentMethodsResponse = await btcpayApi.get<BtcPayGetPaymentMethodsRes>(
`/invoices/${invoice.id}/payment-methods`
)
paymentMethodsResponse.data.forEach((paymentMethod: any) => {
if (paymentMethod.paymentMethod === 'BTC-OnChain') {
bitcoinAddress = paymentMethod.destination
}
if (paymentMethod.paymentMethod === 'XMR') {
moneroAddress = paymentMethod.destination
}
})
if (!bitcoinAddress && process.env.NODE_ENV !== 'development')
throw new Error(
'[/api/funding-required] Could not get bitcoin address from payment methods.'
)
if (!moneroAddress)
throw new Error(
'[/api/funding-required] Could not get monero address from payment methods.'
)
await prisma.projectAddresses.create({
data: {
projectSlug: project.slug,
fundSlug: project.fund,
btcPayInvoiceId: invoice.id,
bitcoinAddress: bitcoinAddress || '',
moneroAddress: moneroAddress,
},
})
}
if (existingAddresses) {
bitcoinAddress = existingAddresses.bitcoinAddress
moneroAddress = existingAddresses.moneroAddress
}
}
const targetAmountBtc = project.goal / (rates.BTC || 0)
const targetAmountXmr = project.goal / (rates.XMR || 0)
const targetAmountUsd = project.goal
const allDonationsSumUsd =
project.totaldonationsinfiatbtc +
project.totaldonationsinfiatxmr +
project.fiattotaldonationsinfiat
const remainingAmountBtc = (project.goal - allDonationsSumUsd) / (rates.BTC || 0)
const remainingAmountXmr = (project.goal - allDonationsSumUsd) / (rates.XMR || 0)
const remainingAmountUsd = project.goal - allDonationsSumUsd
return {
title: project.title,
fund: project.fund,
date: project.date,
author: project.nym,
url: path.join(env.APP_URL, project.fund, project.slug),
is_funded: !!project.isFunded,
target_amount_btc: Number(targetAmountBtc.toFixed(8)),
target_amount_xmr: Number(targetAmountXmr.toFixed(12)),
target_amount_usd: Number(targetAmountUsd.toFixed(2)),
remaining_amount_btc: Number((remainingAmountBtc > 0 ? remainingAmountBtc : 0).toFixed(8)),
remaining_amount_xmr: Number((remainingAmountXmr > 0 ? remainingAmountXmr : 0).toFixed(12)),
remaining_amount_usd: Number((remainingAmountUsd > 0 ? remainingAmountUsd : 0).toFixed(2)),
address_btc: bitcoinAddress,
address_xmr: moneroAddress,
raised_amount_percent:
((project.totaldonationsinfiatxmr +
project.totaldonationsinfiatbtc +
project.fiattotaldonationsinfiat) /
project.goal) *
100,
contributions: project.numdonationsbtc + project.numdonationsxmr + project.fiatnumdonations,
}
})
)
if (query.asset) {
responseBody = responseBody.map<ResponseBodySpecificAsset[0]>((project) => {
const targetAmounts: Record<Asset, number> = {
BTC: project.target_amount_btc,
XMR: project.target_amount_xmr,
USD: project.target_amount_usd,
}
const remainingAmounts: Record<Asset, number> = {
BTC: project.remaining_amount_btc,
XMR: project.remaining_amount_xmr,
USD: project.remaining_amount_usd,
}
const addresses: Record<Asset, string | null> = {
BTC: project.address_btc,
XMR: project.address_xmr,
USD: null,
}
return {
title: project.title,
fund: project.fund,
date: project.date,
author: project.author,
url: project.url,
is_funded: project.is_funded,
target_amount: targetAmounts[query.asset!],
remaining_amount: remainingAmounts[query.asset!],
address: addresses[query.asset!],
raised_amount_percent: project.raised_amount_percent,
contributions: project.contributions,
asset: query.asset!,
}
})
}
// Store response in cache
cachedResponses[cacheKey] = {
data: responseBody,
expiresAt: dayjs().add(10, 'minutes').toDate(),
}
return res.send(responseBody)
}
export default handle

View File

@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "ProjectAddresses" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"projectSlug" TEXT NOT NULL,
"fundSlug" "FundSlug" NOT NULL,
"btcPayInvoiceId" TEXT NOT NULL,
"bitcoinAddress" TEXT NOT NULL,
"moneroAddress" TEXT NOT NULL,
CONSTRAINT "ProjectAddresses_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ProjectAddresses_projectSlug_key" ON "ProjectAddresses"("projectSlug");

View File

@@ -0,0 +1,5 @@
-- DropIndex
DROP INDEX "Donation_btcPayInvoiceId_key";
-- CreateIndex
CREATE INDEX "Donation_btcPayInvoiceId_idx" ON "Donation"("btcPayInvoiceId");

View File

@@ -13,13 +13,20 @@ datasource db {
url = env("DATABASE_URL")
}
enum FundSlug {
monero
firo
privacyguides
general
}
model Donation {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String?
btcPayInvoiceId String? @unique
btcPayInvoiceId String?
stripePaymentIntentId String? // For donations and non-recurring memberships
stripeInvoiceId String? @unique // For recurring memberships
stripeSubscriptionId String? // For recurring memberships
@@ -31,14 +38,20 @@ model Donation {
cryptoAmount Float?
membershipExpiresAt DateTime?
@@index([btcPayInvoiceId])
@@index([stripePaymentIntentId])
@@index([stripeSubscriptionId])
@@index([userId])
}
enum FundSlug {
monero
firo
privacyguides
general
model ProjectAddresses {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projectSlug String @unique
fundSlug FundSlug
btcPayInvoiceId String
bitcoinAddress String
moneroAddress String
}

View File

@@ -8,7 +8,7 @@ import { CURRENCY, MAX_AMOUNT, MEMBERSHIP_PRICE, MIN_AMOUNT } from '../../config
import { env } from '../../env.mjs'
import { btcpayApi, keycloak, prisma, stripe as _stripe } from '../services'
import { authenticateKeycloakClient } from '../utils/keycloak'
import { DonationMetadata } from '../types'
import { BtcPayCreateInvoiceRes, DonationMetadata } from '../types'
import { fundSlugs } from '../../utils/funds'
import { fundSlugToCustomerIdAttr } from '../utils/funds'
@@ -65,6 +65,7 @@ export const donationRouter = router({
isMembership: 'false',
isSubscription: 'false',
isTaxDeductible: input.taxDeductible ? 'true' : 'false',
staticGeneratedForApi: 'false',
}
const params: Stripe.Checkout.SessionCreateParams = {
@@ -130,16 +131,17 @@ export const donationRouter = router({
isMembership: 'false',
isSubscription: 'false',
isTaxDeductible: input.taxDeductible ? 'true' : 'false',
staticGeneratedForApi: 'false',
}
const response = await btcpayApi.post(`/invoices`, {
const { data: invoice } = await btcpayApi.post<BtcPayCreateInvoiceRes>(`/invoices`, {
amount: input.amount,
currency: CURRENCY,
metadata,
checkout: { redirectURL: `${env.APP_URL}/${input.fundSlug}/thankyou` },
})
return { url: response.data.checkoutLink }
return { url: invoice.checkoutLink }
}),
payMembershipWithFiat: protectedProcedure
@@ -199,6 +201,7 @@ export const donationRouter = router({
isMembership: 'true',
isSubscription: input.recurring ? 'true' : 'false',
isTaxDeductible: input.taxDeductible ? 'true' : 'false',
staticGeneratedForApi: 'false',
}
const purchaseParams: Stripe.Checkout.SessionCreateParams = {
@@ -296,16 +299,17 @@ export const donationRouter = router({
isMembership: 'true',
isSubscription: 'false',
isTaxDeductible: input.taxDeductible ? 'true' : 'false',
staticGeneratedForApi: 'false',
}
const response = await btcpayApi.post(`/invoices`, {
const { data: invoice } = await btcpayApi.post<BtcPayCreateInvoiceRes>(`/invoices`, {
amount: MEMBERSHIP_PRICE,
currency: CURRENCY,
metadata,
checkout: { redirectURL: `${env.APP_URL}/${input.fundSlug}/thankyou` },
})
return { url: response.data.checkoutLink }
return { url: invoice.checkoutLink }
}),
donationList: protectedProcedure

View File

@@ -10,4 +10,44 @@ export type DonationMetadata = {
isMembership: 'true' | 'false'
isSubscription: 'true' | 'false'
isTaxDeductible: 'true' | 'false'
staticGeneratedForApi: 'true' | 'false'
}
export type BtcPayGetRatesRes = [
{
currencyPair: string
errors: string[]
rate: string
},
]
export type BtcPayGetPaymentMethodsRes = {
rate: string
amount: string
cryptoCode: string
}[]
export type BtcPayCreateInvoiceBody = {
amount?: number
currency?: string
metadata: DonationMetadata
}
export type BtcPayCreateInvoiceRes = {
metadata: DonationMetadata
checkout: any
receipt: any
id: string
storeId: string
amount: string
currency: string
type: string
checkoutLink: string
createdTime: number
expirationTime: number
monitoringExpiration: number
status: 'Expired' | 'Invalid' | 'New' | 'Processing' | 'Settled'
additionalStatus: string
availableStatusesForManualMarking: any
archived: boolean
}

View File

@@ -18,6 +18,7 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
'Help us to provide sustainable funding for free and open-source contributors working on freedom tech and projects that help Monero flourish.',
coverImage: '/img/crystalball.jpg',
// The attributes below can be ignored
date: '',
goal: 100000,
fund: 'monero',
fiatnumdonations: 0,
@@ -44,6 +45,7 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
summary: 'Support contributors to Firo',
coverImage: '/img/crystalball.jpg',
// The attributes below can be ignored
date: '',
goal: 100000,
fund: 'firo',
fiatnumdonations: 0,
@@ -70,6 +72,7 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
summary: 'Support contributors to Privacy Guides',
coverImage: '/img/crystalball.jpg',
// The attributes below can be ignored
date: '',
goal: 100000,
fund: 'privacyguides',
fiatnumdonations: 0,
@@ -96,6 +99,7 @@ export const funds: Record<FundSlug, ProjectItem & { slug: FundSlug }> = {
summary: 'Support contributors to MAGIC',
coverImage: '/img/crystalball.jpg',
// The attributes below can be ignored
date: '',
goal: 100000,
fund: 'general',
fiatnumdonations: 0,

View File

@@ -106,16 +106,18 @@ export async function getProjects(fundSlug?: FundSlug) {
.flat()
}
// Sort projects
projects = projects
.sort(() => 0.5 - Math.random())
.sort((a, b) => {
// Make active campaigns always come first
// Make active projects always come first
if (!a.isFunded && b.isFunded) return -1
if (a.isFunded && !b.isFunded) return 1
return 0
})
.slice(0, 6)
// Get donation stats for active projects
await Promise.all(
projects.map(async (project) => {
if (project.isFunded) return
@@ -145,6 +147,18 @@ export async function getProjects(fundSlug?: FundSlug) {
project.fiattotaldonationsinfiat += donation.fiatAmount
}
})
// Make isFunded true if goal has been reached
const donationsSum =
((project.totaldonationsinfiatxmr +
project.totaldonationsinfiatbtc +
project.fiattotaldonationsinfiat) /
project.goal) *
100
if (donationsSum >= project.goal) {
project.isFunded = true
}
})
)

View File

@@ -10,7 +10,7 @@ export type ProjectItem = {
coverImage: string
website: string
socialLinks: string[]
date?: string
date: string
staticXMRaddress?: string
goal: number
isFunded?: boolean