├── src ├── types │ ├── btc-price-data.ts │ ├── error.ts │ ├── strike.ts │ ├── supabase.ts │ └── sqala.ts ├── services │ ├── cache.service.ts │ ├── conversion.service.ts │ ├── pix.service.ts │ ├── error.service.ts │ ├── strike.service.ts │ ├── supabase.service.ts │ └── sqala.service.ts ├── app.ts ├── config │ └── index.ts └── routes │ └── index.ts ├── README.md ├── tsconfig.json ├── package.json └── .gitignore /src/types/btc-price-data.ts: -------------------------------------------------------------------------------- 1 | export interface BtcPriceData { 2 | btc_price_brl: number; 3 | last_updated: number; 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nostrpix-api 2 | 3 | Pay anyone in Brazil 🇧🇷 using sats ⚡️ 4 | 5 | frontend: https://github.com/mvuk/nostr-pix-frontend 6 | -------------------------------------------------------------------------------- /src/types/error.ts: -------------------------------------------------------------------------------- 1 | export interface FormattedError { 2 | message: string; 3 | user_data?: { [key: string]: unknown }; 4 | debug_data?: { [key: string]: unknown }; 5 | } 6 | -------------------------------------------------------------------------------- /src/services/cache.service.ts: -------------------------------------------------------------------------------- 1 | import NodeCache from "node-cache"; 2 | 3 | // Cache with a standard TTL of 300 seconds (5 minutes) 4 | const cache = new NodeCache({ stdTTL: 300 }); 5 | export default cache; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "moduleResolution": "node" 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import cors from "cors"; 3 | import helmet from "helmet"; 4 | import routes from "./routes"; 5 | import { port } from "./config"; 6 | 7 | const app = express(); 8 | 9 | app.use(helmet()); 10 | app.use(cors()); 11 | app.use(express.json()); 12 | 13 | app.use("/", routes); 14 | 15 | app.use( 16 | ( 17 | err: Error, 18 | req: express.Request, 19 | res: express.Response, 20 | next: express.NextFunction 21 | ) => { 22 | res.status(500).json({ error: "Something went wrong!" }); 23 | } 24 | ); 25 | 26 | app.listen(port, () => { 27 | console.log(`Server running at http://localhost:${port}`); 28 | }); 29 | 30 | export default app; 31 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | dotenv.config(); 3 | 4 | export const port = process.env.PORT || 3001; 5 | export const sqala_base_url = process.env.SQALA_BASE_URL; 6 | export const sqala_app_id = process.env.SQALA_APP_ID; 7 | export const sqala_app_secret = process.env.SQALA_APP_SECRET; 8 | export const sqala_refresh_token = process.env.SQALA_REFRESH_TOKEN; 9 | export const supabase_url = process.env.SUPABASE_URL as string; 10 | export const supabase_key = process.env.SUPABASE_KEY as string; 11 | export const strike_base_url = process.env.STRIKE_BASE_URL; 12 | export const strike_api_key = process.env.STRIKE_API_KEY; 13 | export const show_debug_data = process.env.SHOW_DEBUG_DATA === "true" || false; 14 | -------------------------------------------------------------------------------- /src/types/strike.ts: -------------------------------------------------------------------------------- 1 | export interface StrikeInvoice { 2 | invoiceId: string; 3 | amount: { 4 | amount: string; 5 | currency: "BTC"; 6 | }; 7 | state: "UNPAID" | "PENDING" | "PAID" | "CANCELLED"; 8 | created: string; 9 | description: string; 10 | issuerId: string; 11 | receiverId: string; 12 | } 13 | 14 | export interface StrikeQuote { 15 | quoteId: string; 16 | description: string; 17 | lnInvoice: string; 18 | expiration: string; 19 | expirationInSec: number; 20 | targetAmount: { 21 | amount: string; 22 | currency: "BTC"; 23 | }; 24 | sourceAmount: { 25 | amount: string; 26 | currency: "BTC"; 27 | }; 28 | conversionRate: { 29 | amount: string; 30 | sourceCurrency: "BTC"; 31 | targetCurrency: "BTC"; 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/types/supabase.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: string; 3 | public_key?: string; 4 | balance_sats: number; // integer 5 | created_at: string; 6 | } 7 | 8 | export interface UserPixPayment { 9 | id: string; 10 | amount_brl: number; // decimal, 2 places 11 | amount_sats: number; // integer 12 | payee_name: string; 13 | description?: string; 14 | pix_key?: string; 15 | pix_qr_code?: string; 16 | sqala_id: string; 17 | user_id: string; // foreign key to user.id 18 | paid: boolean; 19 | created_at: string; 20 | } 21 | 22 | export interface UserLightningDeposit { 23 | id: string; 24 | amount_sats: number; // integer 25 | lnurl: string; 26 | description?: string; 27 | strike_id: string; 28 | user_id: string; // foreign key to user.id 29 | paid: boolean; 30 | created_at: string; 31 | } 32 | 33 | export type UserInsert = Omit; 34 | export type UserPixPaymentInsert = Omit; 35 | export type UserLightningDepositInsert = Omit< 36 | UserLightningDeposit, 37 | "id" | "created_at" 38 | >; 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nostrpix-api", 3 | "scripts": { 4 | "start": "node dist/app.js", 5 | "dev": "nodemon src/app.ts", 6 | "build": "tsc", 7 | "watch": "tsc -w" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/gringokiwi/nostrpix-api.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/gringokiwi/nostrpix-api/issues" 15 | }, 16 | "homepage": "https://github.com/gringokiwi/nostrpix-api#readme", 17 | "dependencies": { 18 | "@supabase/supabase-js": "^2.48.1", 19 | "@types/cors": "^2.8.17", 20 | "@types/express": "^5.0.0", 21 | "@types/node": "^22.13.4", 22 | "axios": "^1.7.9", 23 | "base-64": "^1.0.0", 24 | "cors": "^2.8.5", 25 | "cpf-cnpj-validator": "^1.0.3", 26 | "dotenv": "^16.4.7", 27 | "email-validator": "^2.0.4", 28 | "express": "^4.21.2", 29 | "helmet": "^8.0.0", 30 | "node-cache": "^5.1.2", 31 | "phone": "^3.1.58", 32 | "ts-node": "^10.9.2", 33 | "typescript": "^5.7.3", 34 | "uuid": "^11.1.0" 35 | }, 36 | "devDependencies": { 37 | "@types/base-64": "^1.0.2", 38 | "@typescript-eslint/eslint-plugin": "^8.24.1", 39 | "@typescript-eslint/parser": "^8.24.1", 40 | "nodemon": "^3.1.9" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/services/conversion.service.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import cache from "./cache.service"; 3 | import { BtcPriceData } from "../types/btc-price-data"; 4 | import { CustomError } from "./error.service"; 5 | 6 | export const fetch_btc_price_brl = async (): Promise => { 7 | const response = await axios.get("https://mempool.space/api/v1/prices"); 8 | const btc_price_usd = Number(response.data.USD); 9 | const btc_price_brl = btc_price_usd * 5.7; 10 | if (!btc_price_brl) { 11 | throw new CustomError("Could not fetch BTC price", {}, response.data); 12 | } 13 | return { btc_price_brl, last_updated: Date.now() }; 14 | }; 15 | 16 | export const get_btc_price_data = async (): Promise => { 17 | let cached_price_data = cache.get("btc_price_data"); 18 | if (cached_price_data !== undefined) { 19 | return cached_price_data; 20 | } 21 | const fetched_price_data = await fetch_btc_price_brl(); 22 | cache.set("btc_price_data", fetched_price_data); 23 | return fetched_price_data; 24 | }; 25 | 26 | export const convert_brl_to_sats = ( 27 | amount_brl_decimal: number, 28 | btc_price_brl: number 29 | ): number => { 30 | const amount_sats = (amount_brl_decimal / btc_price_brl) * 100_000_000; 31 | return Math.floor(amount_sats); 32 | }; 33 | -------------------------------------------------------------------------------- /src/types/sqala.ts: -------------------------------------------------------------------------------- 1 | export interface SqalaDepositResponse { 2 | id: string; 3 | code: string; 4 | method: string; 5 | amount: number; 6 | payer: unknown | null; 7 | split: unknown[]; 8 | status: string; 9 | createdAt: string; 10 | processedAt: string; 11 | paidAt: string | null; 12 | failedAt: string | null; 13 | metadata: {}; 14 | payload: string; 15 | type: string; 16 | expiresAt: string; 17 | receiptUrl: string | null; 18 | } 19 | 20 | export interface SqalaDictLookupResponse { 21 | dictId: string; 22 | amount: number; 23 | hash: string; 24 | key: string; 25 | recipient: { 26 | name: string; 27 | type: string; 28 | taxId: string; 29 | }; 30 | bankAccount: { 31 | branchNumber: string; 32 | accountNumber: string; 33 | bankId: string; 34 | bankName: string; 35 | }; 36 | } 37 | 38 | export interface SqalaWithdrawalResponse { 39 | id: string; 40 | code: string; 41 | amount: number; 42 | status: string; 43 | method: string; 44 | createdAt: string; 45 | approvedAt: string | null; 46 | rejectedAt: string | null; 47 | paidAt: string | null; 48 | failedAt: string | null; 49 | failedReason: string | null; 50 | pixKey: string; 51 | transactionId: string | null; 52 | expectedHolderTaxId: string | null; 53 | receiptUrl: string | null; 54 | recipient: { 55 | id: string; 56 | code: string; 57 | name: string; 58 | taxId: string | null; 59 | type: string; 60 | status: string; 61 | createdAt: string | null; 62 | updatedAt: string | null; 63 | deletedAt: string | null; 64 | }; 65 | metadata: { 66 | [key: string]: string; 67 | }; 68 | } 69 | 70 | export interface SqalaBalanceResponse { 71 | available: number; 72 | } 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /src/services/pix.service.ts: -------------------------------------------------------------------------------- 1 | import { cpf, cnpj } from "cpf-cnpj-validator"; 2 | import { phone } from "phone"; 3 | import * as email_validator from "email-validator"; 4 | import { validate as uuidValidate } from "uuid"; 5 | import { convert_brl_to_sats, get_btc_price_data } from "./conversion.service"; 6 | import { CustomError } from "./error.service"; 7 | 8 | export const validate_pix_key = ( 9 | pix_key: string 10 | ): { 11 | is_valid: boolean; 12 | formatted_key?: string; 13 | } => { 14 | if (cpf.isValid(pix_key)) { 15 | return { 16 | is_valid: true, 17 | formatted_key: cpf.strip(pix_key), 18 | }; 19 | } 20 | if (cnpj.isValid(pix_key)) { 21 | return { 22 | is_valid: true, 23 | formatted_key: cnpj.strip(pix_key), 24 | }; 25 | } 26 | const phone_result = pix_key.includes("+") 27 | ? phone(pix_key) 28 | : phone(pix_key, { country: "BRA" }); 29 | if (phone_result.isValid) { 30 | return { 31 | is_valid: true, 32 | formatted_key: phone_result.phoneNumber, 33 | }; 34 | } 35 | if (email_validator.validate(pix_key)) { 36 | return { 37 | is_valid: true, 38 | formatted_key: pix_key, 39 | }; 40 | } 41 | if (uuidValidate(pix_key)) { 42 | return { 43 | is_valid: true, 44 | formatted_key: pix_key, 45 | }; 46 | } 47 | throw new CustomError("Invalid 'pix_key'", { 48 | pix_key, 49 | }); 50 | }; 51 | 52 | export const pix_amount_minimum = 1; 53 | export const pix_amount_maximum = 50; 54 | 55 | export const validate_pix_amount = async ( 56 | amount_brl_decimal: number, 57 | override_limits?: boolean 58 | ): Promise<{ 59 | is_valid: boolean; 60 | amount_brl_cents: number; 61 | amount_brl_decimal: number; 62 | adjusted_amount_brl_cents: number; 63 | adjusted_amount_brl_decimal: number; 64 | adjusted_amount_sats: number; 65 | }> => { 66 | if (!override_limits && amount_brl_decimal < pix_amount_minimum) { 67 | throw new CustomError( 68 | `'amount' must be greater than ${pix_amount_minimum}`, 69 | { 70 | pix_amount_minimum, 71 | } 72 | ); 73 | } 74 | if (!override_limits && amount_brl_decimal > pix_amount_maximum) { 75 | throw new CustomError(`'amount' must be lower than ${pix_amount_maximum}`, { 76 | pix_amount_maximum, 77 | }); 78 | } 79 | // Account for Sqala's 1% fee 80 | const amount_brl_cents = amount_brl_decimal * 100; 81 | const adjusted_amount_brl_decimal = 82 | Math.round(amount_brl_cents / (1 - 0.01)) / 100; 83 | const adjusted_amount_brl_cents = adjusted_amount_brl_decimal * 100; 84 | const { btc_price_brl } = await get_btc_price_data(); 85 | const adjusted_amount_sats = convert_brl_to_sats( 86 | // Adjust for 5% BTCBRL spread 87 | Math.floor(adjusted_amount_brl_decimal / (1 - 0.05)), 88 | btc_price_brl 89 | ); 90 | return { 91 | is_valid: true, 92 | amount_brl_cents, 93 | amount_brl_decimal, 94 | adjusted_amount_brl_cents, 95 | adjusted_amount_brl_decimal, 96 | adjusted_amount_sats, 97 | }; 98 | }; 99 | -------------------------------------------------------------------------------- /src/services/error.service.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError, AxiosResponse } from "axios"; 2 | import { Request, Response, NextFunction } from "express"; 3 | import { show_debug_data } from "../config"; 4 | import { FormattedError } from "../types/error"; 5 | 6 | export class CustomError extends Error { 7 | user_data?: { [key: string]: unknown }; 8 | debug_data?: { [key: string]: unknown }; 9 | constructor( 10 | message: FormattedError["message"], 11 | user_data?: FormattedError["user_data"], 12 | debug_data?: FormattedError["debug_data"] 13 | ) { 14 | super(message); 15 | this.name = "CustomError"; 16 | this.user_data = user_data; 17 | this.debug_data = debug_data; 18 | } 19 | } 20 | 21 | const errorToDebugObject = (error: Error): { [key: string]: unknown } => { 22 | return Object.getOwnPropertyNames(error).reduce((acc, key) => { 23 | acc[key] = error[key as keyof Error]; 24 | return acc; 25 | }, {} as { [key: string]: unknown }); 26 | }; 27 | 28 | export const parse_error = (error: unknown): FormattedError => { 29 | console.error(error); 30 | 31 | // Handle CustomError directly 32 | if (error instanceof CustomError) { 33 | return { 34 | message: error.message, 35 | user_data: error.user_data, 36 | debug_data: show_debug_data ? error.debug_data : undefined, 37 | }; 38 | } 39 | 40 | // Handle Axios errors 41 | if (error instanceof AxiosError) { 42 | return parse_error(error.response?.data || error.message); 43 | } 44 | 45 | // Handle standard Error objects 46 | if (error instanceof Error) { 47 | return { 48 | message: error.message, 49 | debug_data: show_debug_data ? errorToDebugObject(error) : undefined, 50 | }; 51 | } 52 | 53 | // Handle string errors (possibly JSON) 54 | if (typeof error === "string") { 55 | try { 56 | return parse_error(JSON.parse(error)); 57 | } catch { 58 | return { 59 | message: error, 60 | }; 61 | } 62 | } 63 | 64 | // Handle object errors 65 | if (typeof error === "object" && error !== null) { 66 | const typecasted_error = error as { [key: string]: unknown }; 67 | 68 | // Handle nested errors 69 | if (typecasted_error.error) { 70 | return parse_error(typecasted_error.error); 71 | } 72 | 73 | // Handle Axios-like responses 74 | if (typecasted_error.response) { 75 | return parse_error((typecasted_error.response as AxiosResponse).data); 76 | } 77 | 78 | // Use 'code' as message if available 79 | const message = 80 | typeof typecasted_error.code === "string" 81 | ? typecasted_error.code 82 | : "An unknown error occurred"; 83 | 84 | return { 85 | message, 86 | debug_data: show_debug_data ? typecasted_error : undefined, 87 | }; 88 | } 89 | 90 | // Default case 91 | return { 92 | message: "An unknown error occurred", 93 | debug_data: show_debug_data ? { original: error } : undefined, 94 | }; 95 | }; 96 | 97 | export const async_handler = ( 98 | fn: (req: Request, res: Response, next: NextFunction) => Promise 99 | ) => { 100 | return async (req: Request, res: Response, next: NextFunction) => { 101 | try { 102 | await fn(req, res, next); 103 | } catch (error) { 104 | if (!res.headersSent) { 105 | res.status(500).json({ error: parse_error(error) }); 106 | } 107 | } 108 | }; 109 | }; 110 | -------------------------------------------------------------------------------- /src/services/strike.service.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { strike_api_key, strike_base_url } from "../config"; 3 | import { StrikeInvoice, StrikeQuote } from "../types/strike"; 4 | import { 5 | get_user_lightning_deposit, 6 | get_user, 7 | list_user_lightning_deposits, 8 | insert_user_lightning_deposit, 9 | update_user_lightning_deposit_paid, 10 | } from "./supabase.service"; 11 | import { UserLightningDeposit } from "../types/supabase"; 12 | import { CustomError } from "./error.service"; 13 | 14 | const strike_api_client = axios.create({ 15 | baseURL: strike_base_url, 16 | headers: { 17 | "Content-Type": "application/json", 18 | Authorization: `Bearer ${strike_api_key}`, 19 | }, 20 | }); 21 | 22 | export const generate_lightning_deposit = async ( 23 | amount_sats: number, 24 | user_id: string 25 | ): Promise => { 26 | const user = await get_user(user_id); 27 | if (!user) { 28 | throw new CustomError("User not found", { 29 | user_id, 30 | }); 31 | } 32 | const { invoiceId: strike_id } = await strike_api_client 33 | .post("/invoices", { 34 | description: "Topup NostrPIX account", 35 | amount: { 36 | amount: amount_sats / 100_000_000, 37 | currency: "BTC", 38 | }, 39 | }) 40 | .then((response) => response.data as StrikeInvoice); 41 | const { lnInvoice: lnurl } = await strike_api_client 42 | .post(`/invoices/${strike_id}/quote`) 43 | .then((response) => response.data as StrikeQuote); 44 | const lightning_deposit = await insert_user_lightning_deposit({ 45 | amount_sats, 46 | lnurl, 47 | strike_id, 48 | user_id, 49 | paid: false, 50 | }); 51 | return lightning_deposit; 52 | }; 53 | 54 | export const check_lightning_deposit_statuses = async ( 55 | user_id: string 56 | ): Promise => { 57 | const lightning_deposits = await list_user_lightning_deposits(user_id); 58 | const unpaid_lightning_deposits = lightning_deposits.filter( 59 | ({ paid }) => !paid 60 | ); 61 | const updated_lightning_deposits = await Promise.all( 62 | unpaid_lightning_deposits.map(async (lightning_deposit) => { 63 | const { 64 | id: lightning_deposit_id, 65 | strike_id, 66 | amount_sats, 67 | } = lightning_deposit; 68 | const { state } = await strike_api_client 69 | .get(`/invoices/${strike_id}`) 70 | .then((response) => response.data as StrikeInvoice); 71 | if (state === "PAID") { 72 | const updated_lightning_deposit = 73 | await update_user_lightning_deposit_paid( 74 | lightning_deposit_id, 75 | user_id, 76 | amount_sats 77 | ); 78 | return updated_lightning_deposit; 79 | } 80 | return lightning_deposit; 81 | }) 82 | ); 83 | return updated_lightning_deposits; 84 | }; 85 | 86 | export const check_lightning_deposit_status = async ( 87 | lnurl: string 88 | ): Promise< 89 | UserLightningDeposit & { 90 | state: StrikeInvoice["state"]; 91 | } 92 | > => { 93 | const { strike_id, user_id } = await get_user_lightning_deposit(lnurl); 94 | const { state } = await strike_api_client 95 | .get(`/invoices/${strike_id}`) 96 | .then((response) => response.data as StrikeInvoice); 97 | if (state === "PAID") { 98 | await check_lightning_deposit_statuses(user_id); 99 | } 100 | const updated_lightning_deposit = await get_user_lightning_deposit(lnurl); 101 | return { ...updated_lightning_deposit, state }; 102 | }; 103 | -------------------------------------------------------------------------------- /src/services/supabase.service.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@supabase/supabase-js"; 2 | import { 3 | User, 4 | UserLightningDeposit, 5 | UserLightningDepositInsert, 6 | UserPixPaymentInsert, 7 | UserPixPayment, 8 | } from "../types/supabase"; 9 | import { supabase_url, supabase_key } from "../config"; 10 | 11 | const supabase = createClient(supabase_url, supabase_key); 12 | 13 | export const insert_user = async (): Promise => { 14 | const { data, error } = await supabase 15 | .from("users") 16 | .insert({ balance_sats: 0 }) 17 | .select() 18 | .single(); 19 | if (error) throw error; 20 | return data; 21 | }; 22 | 23 | export const get_user = async (user_id: string): Promise => { 24 | const { data, error } = await supabase 25 | .from("users") 26 | .select() 27 | .eq("id", user_id) 28 | .single(); 29 | if (error) throw error; 30 | return data; 31 | }; 32 | 33 | export const update_user_balance_sats = async ( 34 | user_id: string, 35 | balance_change_sats: number 36 | ): Promise => { 37 | const { data, error } = await supabase 38 | .rpc("update_user_balance", { 39 | p_user_id: user_id, 40 | p_balance_change: balance_change_sats, 41 | }) 42 | .returns() 43 | .single(); 44 | if (error) throw error; 45 | return data; 46 | }; 47 | 48 | export const insert_user_lightning_deposit = async ( 49 | lightning_deposit: UserLightningDepositInsert 50 | ): Promise => { 51 | const { data, error } = await supabase 52 | .from("lightning_deposits") 53 | .insert(lightning_deposit) 54 | .select() 55 | .single(); 56 | if (error) throw error; 57 | return data; 58 | }; 59 | 60 | export const update_user_lightning_deposit_paid = async ( 61 | lightning_deposit_id: string, 62 | user_id: string, 63 | amount_sats: number, 64 | update_balance: boolean = true 65 | ): Promise => { 66 | const { data, error } = await supabase 67 | .rpc("process_lightning_deposit", { 68 | p_deposit_id: lightning_deposit_id, 69 | p_user_id: user_id, 70 | p_amount_sats: amount_sats, 71 | p_update_balance: update_balance, 72 | }) 73 | .returns() 74 | .single(); 75 | if (error) throw error; 76 | return data; 77 | }; 78 | 79 | export const insert_user_pix_payment = async ( 80 | pix_payment: UserPixPaymentInsert 81 | ): Promise => { 82 | const { data, error } = await supabase 83 | .from("pix_payments") 84 | .insert(pix_payment) 85 | .select() 86 | .single(); 87 | if (error) throw error; 88 | return data; 89 | }; 90 | 91 | export const get_user_lightning_deposit = async ( 92 | lnurl: string 93 | ): Promise => { 94 | const { data, error } = await supabase 95 | .from("lightning_deposits") 96 | .select() 97 | .eq("lnurl", lnurl) 98 | .single(); 99 | if (error) throw error; 100 | return data; 101 | }; 102 | 103 | export const list_user_pix_payments = async ( 104 | user_id: string 105 | ): Promise => { 106 | const { data, error } = await supabase 107 | .from("pix_payments") 108 | .select() 109 | .eq("user_id", user_id); 110 | if (error) throw error; 111 | return data; 112 | }; 113 | 114 | export const list_user_lightning_deposits = async ( 115 | user_id: string 116 | ): Promise => { 117 | const { data, error } = await supabase 118 | .from("lightning_deposits") 119 | .select() 120 | .eq("user_id", user_id); 121 | if (error) throw error; 122 | return data; 123 | }; 124 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from "express"; 2 | import { 3 | get_admin_deposit_qr, 4 | get_admin_balance_brl, 5 | pay_pix_via_qr, 6 | pay_pix_via_key, 7 | } from "../services/sqala.service"; 8 | import { async_handler, CustomError } from "../services/error.service"; 9 | import { 10 | insert_user, 11 | get_user, 12 | list_user_lightning_deposits, 13 | list_user_pix_payments, 14 | } from "../services/supabase.service"; 15 | import { validate_pix_amount } from "../services/pix.service"; 16 | import { 17 | check_lightning_deposit_status, 18 | check_lightning_deposit_statuses, 19 | generate_lightning_deposit, 20 | } from "../services/strike.service"; 21 | 22 | const router = Router(); 23 | 24 | router.get( 25 | "/admin/balance", 26 | async_handler(async (req, res) => { 27 | const response = await get_admin_balance_brl(); 28 | res.json(response); 29 | }) 30 | ); 31 | 32 | router.get( 33 | "/admin/deposit", 34 | async_handler(async (req, res) => { 35 | res.status(500).send("Disabled"); 36 | }) 37 | ); 38 | 39 | router.get( 40 | "/quote", 41 | async_handler(async (req: Request, res: Response) => { 42 | const amount_brl_decimal = Number(req.query.amount_brl); 43 | if (isNaN(amount_brl_decimal)) { 44 | throw new CustomError(`Invalid or missing 'amount_brl'`); 45 | } 46 | const { adjusted_amount_sats } = await validate_pix_amount( 47 | amount_brl_decimal 48 | ); 49 | const response = { 50 | amount_brl: amount_brl_decimal, 51 | amount_sats: adjusted_amount_sats, 52 | }; 53 | res.json(response); 54 | }) 55 | ); 56 | 57 | router.get( 58 | "/user/new", 59 | async_handler(async (req, res) => { 60 | const user = await insert_user(); 61 | return res.json(user); 62 | }) 63 | ); 64 | 65 | router.get( 66 | "/user/:user_id/details", 67 | async_handler(async (req, res) => { 68 | if (!req.params.user_id) { 69 | throw new CustomError(`Missing 'user_id'`); 70 | } 71 | const user = await get_user(String(req.params.user_id)); 72 | const pix_payments = await list_user_pix_payments(user.id); 73 | const lightning_deposits = await list_user_lightning_deposits(user.id); 74 | const response = { 75 | user, 76 | pix_payments, 77 | lightning_deposits, 78 | }; 79 | return res.json(response); 80 | }) 81 | ); 82 | 83 | router.get( 84 | "/user/:user_id/details/refresh", 85 | async_handler(async (req, res) => { 86 | if (!req.params.user_id) { 87 | throw new CustomError(`Missing 'user_id'`); 88 | } 89 | await check_lightning_deposit_statuses(String(req.params.user_id)); 90 | const user = await get_user(String(req.params.user_id)); 91 | const pix_payments = await list_user_pix_payments(user.id); 92 | const lightning_deposits = await list_user_lightning_deposits(user.id); 93 | const response = { 94 | user, 95 | pix_payments, 96 | lightning_deposits, 97 | }; 98 | return res.json(response); 99 | }) 100 | ); 101 | 102 | router.get( 103 | "/user/:user_id/deposit/new", 104 | async_handler(async (req, res) => { 105 | res.status(500).send("Disabled"); 106 | }) 107 | ); 108 | 109 | router.get( 110 | "/user/:user_id/deposit/:lnurl", 111 | async_handler(async (req, res) => { 112 | if (!req.params.lnurl) { 113 | throw new CustomError(`Missing 'lnurl'`); 114 | } 115 | const response = await check_lightning_deposit_status( 116 | String(req.params.lnurl) 117 | ); 118 | res.json(response); 119 | }) 120 | ); 121 | 122 | router.get( 123 | "/user/:user_id/pay", 124 | async_handler(async (req, res) => { 125 | res.status(500).send("Disabled"); 126 | }) 127 | ); 128 | 129 | export default router; 130 | -------------------------------------------------------------------------------- /src/services/sqala.service.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { 3 | sqala_base_url, 4 | sqala_app_id, 5 | sqala_app_secret, 6 | sqala_refresh_token, 7 | } from "../config"; 8 | import { encode } from "base-64"; 9 | import { validate_pix_key, validate_pix_amount } from "./pix.service"; 10 | import { 11 | SqalaBalanceResponse, 12 | SqalaDepositResponse, 13 | SqalaDictLookupResponse, 14 | SqalaWithdrawalResponse, 15 | } from "../types/sqala"; 16 | import { 17 | get_user, 18 | insert_user_pix_payment, 19 | update_user_balance_sats, 20 | } from "./supabase.service"; 21 | import { UserPixPayment } from "../types/supabase"; 22 | import { CustomError } from "./error.service"; 23 | import { check_lightning_deposit_statuses } from "./strike.service"; 24 | 25 | const sqala_api_client = axios.create({ 26 | baseURL: sqala_base_url, 27 | headers: { 28 | "Content-Type": "application/json", 29 | }, 30 | }); 31 | 32 | sqala_api_client.interceptors.request.use( 33 | async (config) => { 34 | if (config.url === "/access-tokens") { 35 | config.headers.Authorization = 36 | "Basic " + encode(sqala_app_id + ":" + sqala_app_secret); 37 | return config; 38 | } 39 | const token = await get_access_token(); 40 | config.headers.Authorization = `Bearer ${token}`; 41 | return config; 42 | }, 43 | (error) => { 44 | return Promise.reject(error); 45 | } 46 | ); 47 | 48 | let access_token: string | null = null; 49 | let token_expiry_time: number | null = null; 50 | 51 | const get_access_token = async (): Promise => { 52 | if (access_token && token_expiry_time && Date.now() < token_expiry_time) { 53 | return access_token; 54 | } 55 | const response = await sqala_api_client.post("/access-tokens", { 56 | refreshToken: sqala_refresh_token, 57 | }); 58 | if (!response.data?.token) { 59 | throw new CustomError( 60 | "Could not connect to Pix service", 61 | {}, 62 | response.data 63 | ); 64 | } 65 | access_token = response.data.token as string; 66 | token_expiry_time = Date.now() + response.data.expiresIn * 1000; 67 | return access_token; 68 | }; 69 | 70 | export const get_admin_deposit_qr = async ({ 71 | amount_brl_decimal, 72 | }: { 73 | amount_brl_decimal: number; 74 | }): Promise<{ 75 | adjusted_amount_brl: number; 76 | deposit_qr_code: string; 77 | }> => { 78 | const { adjusted_amount_brl_cents, adjusted_amount_brl_decimal } = 79 | await validate_pix_amount(amount_brl_decimal, true); 80 | const { payload: deposit_qr_code } = await sqala_api_client 81 | .post(`/pix-qrcode-payments`, { 82 | amount: adjusted_amount_brl_cents, 83 | }) 84 | .then((response) => response.data as SqalaDepositResponse); 85 | return { adjusted_amount_brl: adjusted_amount_brl_decimal, deposit_qr_code }; 86 | }; 87 | 88 | export const get_admin_balance_brl = async (): Promise<{ 89 | balance_brl: number; 90 | }> => { 91 | const { available: balance_brl_cents } = await sqala_api_client 92 | .get(`/recipients/DEFAULT/balance`) 93 | .then((response) => response.data as SqalaBalanceResponse); 94 | return { balance_brl: balance_brl_cents / 100 }; 95 | }; 96 | 97 | export const pay_pix_via_qr = async ({ 98 | qr_code, 99 | user_id, 100 | }: { 101 | qr_code: string; 102 | user_id: string; 103 | }): Promise< 104 | UserPixPayment & { 105 | status: string; 106 | } 107 | > => { 108 | await check_lightning_deposit_statuses(user_id); 109 | const { balance_sats } = await get_user(user_id); 110 | const { 111 | hash, 112 | amount: amount_brl_cents, 113 | key: pix_key, 114 | recipient: { name: payee_name }, 115 | } = await sqala_api_client 116 | .get(`/dict/barcode?qrcode=${qr_code}`) 117 | .then((response) => response.data as SqalaDictLookupResponse); 118 | const amount_brl_decimal = amount_brl_cents / 100; 119 | const { adjusted_amount_sats } = await validate_pix_amount( 120 | amount_brl_decimal 121 | ); 122 | if (adjusted_amount_sats > balance_sats) { 123 | const minimum_topup_amount = adjusted_amount_sats - balance_sats; 124 | const recommended_topup_amount = Math.floor(minimum_topup_amount * 1.05); 125 | throw new CustomError( 126 | `Insufficient balance for payment - have ${balance_sats} sats, need >${adjusted_amount_sats} sats - need to top up ~${recommended_topup_amount} sats`, 127 | { 128 | balance_sats, 129 | minimum_topup_amount, 130 | recommended_topup_amount, 131 | } 132 | ); 133 | } 134 | const { status, id: sqala_id } = await sqala_api_client 135 | .post(`/recipients/DEFAULT/withdrawals`, { 136 | method: "PIX_QRCODE", 137 | pixQrCode: qr_code, 138 | hash, 139 | amount: amount_brl_cents, 140 | }) 141 | .then((response) => response.data as SqalaWithdrawalResponse); 142 | await update_user_balance_sats(user_id, -adjusted_amount_sats); 143 | const pix_payment_record = await insert_user_pix_payment({ 144 | amount_brl: amount_brl_decimal, 145 | amount_sats: adjusted_amount_sats, 146 | payee_name, 147 | pix_key, 148 | pix_qr_code: qr_code, 149 | sqala_id, 150 | user_id, 151 | paid: true, 152 | }); 153 | return { ...pix_payment_record, status }; 154 | }; 155 | 156 | export const pay_pix_via_key = async ({ 157 | pix_key, 158 | amount_brl_decimal, 159 | user_id, 160 | }: { 161 | pix_key: string; 162 | amount_brl_decimal: number; 163 | user_id: string; 164 | }): Promise< 165 | UserPixPayment & { 166 | status: string; 167 | } 168 | > => { 169 | await check_lightning_deposit_statuses(user_id); 170 | const { balance_sats } = await get_user(user_id); 171 | const { amount_brl_cents, adjusted_amount_sats } = await validate_pix_amount( 172 | amount_brl_decimal 173 | ); 174 | if (adjusted_amount_sats > balance_sats) { 175 | const minimum_topup_amount = adjusted_amount_sats - balance_sats; 176 | const recommended_topup_amount = Math.floor(minimum_topup_amount * 1.05); 177 | throw new CustomError( 178 | `Insufficient balance for payment - have ${balance_sats} sats, need >${adjusted_amount_sats} sats - need to top up ~${recommended_topup_amount} sats`, 179 | { 180 | balance_sats, 181 | minimum_topup_amount, 182 | recommended_topup_amount, 183 | } 184 | ); 185 | } 186 | const { formatted_key } = validate_pix_key(pix_key); 187 | const { 188 | status, 189 | id: sqala_id, 190 | pixKey: payee_name, 191 | } = await sqala_api_client 192 | .post(`/recipients/DEFAULT/withdrawals`, { 193 | method: "PIX", 194 | amount: amount_brl_cents, 195 | pixKey: formatted_key, 196 | }) 197 | .then((response) => response.data as SqalaWithdrawalResponse); 198 | await update_user_balance_sats(user_id, -adjusted_amount_sats); 199 | const pix_payment_record = await insert_user_pix_payment({ 200 | amount_brl: amount_brl_decimal, 201 | amount_sats: adjusted_amount_sats, 202 | payee_name, 203 | pix_key, 204 | user_id, 205 | sqala_id, 206 | paid: true, 207 | }); 208 | return { ...pix_payment_record, status }; 209 | }; 210 | --------------------------------------------------------------------------------