Skip to content

Commit

Permalink
Add LN payments to Damus Purple
Browse files Browse the repository at this point in the history
This adds initial LN payments support

The overall flow is:
1. Client website requests a list of available products (the different subscription options)
2. User starts a new checkout by selecting a product and getting a new checkout object with a random UUID
3. User verifies their npub with a NIP-98 authenticated call. Their npub gets attached to the checkout object, an invoice is generated
4. At this point, the checkout object is immutable, to avoid any security issues or undefined behavior
5. The client will get the invoice information, including connection parameters, and monitor the invoice status
6. Once the client detects payment, it will tell the server to double-check the invoice status
7. At this point, if the server confirms the invoice is really paid, it will:
    - Bump or set the expiry date into the future
    - Create an account for the user if needed
    - Return a finalized checkout object for the client side to display the final confirmation.

Testing
--------

Device: iPhone 15 Pro simulator
iOS: 17.2
damus-api: This commit
damus-website: `2ee775f209db6459ce2c42529590651056cb577f`
damus: `1f28075721e332fccab153499a873293bb5522c3`

Setup:
- Purple experimental feature enabled
- localhost setup enabled
- Website running on localhost:3000 with `npm run dev`
- damus-api running on localhost:8989 with `npm run dev`
- Connected to LN node at ln.damus.io

damus-api .env:
BASE_URL=http://localhost:8989
DEEPL_KEY=123
LN_NODE_ADDRESS="ln.damus.io"
LN_WS_PROXY="proxy.lnlink.org"
LN_NODE_ID="03f3c108ccd536b8526841f0a5c58212bb9e6584a1eb493080e7c1cc34f82dad71"
LN_RUNE="AK_DTEGChRRn3z_Zqjtny5ffNOjxTEl-pLEd-OpbZw09NiZtZXRob2Q9aW52b2ljZXxtZXRob2Q9d2FpdGludm9pY2UmcG5hbWVsYWJlbF5sbmxpbmstJnJhdGU9MjA="
TEST_PRODUCTS="true"

damus-website .env:
NEXT_PUBLIC_PURPLE_API_BASE_URL=http://localhost:8989

Steps:
1. Open Damus Purple screen on iOS app
2. Click on "Learn more" to open the website
3. Click on "Subscribe" on the website to go to the checkout page. Ensure the checkout page appears. PASS
4. Make sure product options appear on the checkout page. PASS
5. Click "Open in Damus" under "Verify npub" to open the Damus app. Ensure the app opens on the Verify npub screen. PASS
6. Click "Verify" to verify the npub. Ensure that a success message appears. PASS
7. Go back to the website. Ensure that the "Verify npub" step is complete, and a lightning invoice appears. PASS
8. Pay the invoice. Once complete, make sure a confirmation message appears. PASS
9. Click on the link to continue in Damus, and ensure that the app opens on the "Welcome" screen. PASS
10. Repeat steps 1-9 5 times to check this flow is reasonably robust. Ensure that none of the 4 systems involved (iOS app, damus-api, damus-website, LN node) crash or hang. PASS

Changelog-Added: Add LN payments to Damus Purple
Signed-off-by: Daniel D’Aquino <[email protected]>
Signed-off-by: William Casarin <[email protected]>
  • Loading branch information
danieldaquino authored and jb55 committed Jan 22, 2024
1 parent 1d0edbb commit a861bbd
Show file tree
Hide file tree
Showing 4 changed files with 364 additions and 6 deletions.
6 changes: 5 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const Router = require('./server_helpers').Router
const config_router = require('./router_config').config_router
const dotenv = require('dotenv')
const express = require('express')
const { PurpleInvoiceManager } = require('./invoicing')

function PurpleApi(opts = {}) {
if (!(this instanceof PurpleApi))
Expand All @@ -15,7 +16,8 @@ function PurpleApi(opts = {}) {
const db = lmdb.open({ path: '.' })
const translations = db.openDB('translations')
const accounts = db.openDB('accounts')
const dbs = { translations, accounts }
const invoices = db.openDB('invoices')
const dbs = { translations, accounts, invoices }
const router = express()

// translation data
Expand All @@ -25,6 +27,8 @@ function PurpleApi(opts = {}) {
this.dbs = dbs
this.opts = opts
this.router = router
this.invoice_manager = new PurpleInvoiceManager(this, process.env.LN_NODE_ID, process.env.LN_NODE_ADDRESS, process.env.LN_RUNE, process.env.LN_WS_PROXY)
this.invoice_manager.connect_and_init()

return this
}
Expand Down
224 changes: 224 additions & 0 deletions src/invoicing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
const LNSocket = require('lnsocket')
const { bump_expiry } = require('./user_management')
const { nip19 } = require('nostr-tools')
const { v4: uuidv4 } = require('uuid')

const PURPLE_ONE_MONTH = "purple_one_month"
const PURPLE_ONE_YEAR = "purple_one_year"
const PURGE_OLD_INVOICES = false // Disabled for now as we would like to keep records of all invoices

function getInvoiceTemplates() {
return process.env.TEST_PRODUCTS ?
{
"purple_one_month": {
description: "Purple (1 mo) (test)",
amount_msat: 1000, // Make a cheap invoice for testing
expiry: 30 * 24 * 60 * 60 // 30 days
},
"purple_one_year": {
description: "Purple (1 yr) (test)",
amount_msat: 1000, // Make a cheap invoice for testing
expiry: 365 * 24 * 60 * 60 // 365 days
}
}
:
{
"purple_one_month": {
description: "Purple (One Month)",
// TODO: Make this value change based on the exchange rate of BTC vs USD (or not? 1 BTC = 1 BTC, USD is a shitcoin since 1971)
amount_msat: 15000 * 1000, // 15k sats / month
expiry: 30 * 24 * 60 * 60 // 30 days
},
"purple_one_year": {
description: "Purple (One Year)",
// TODO: Make this value change based on the exchange rate of BTC vs USD (or not? 1 BTC = 1 BTC, USD is a shitcoin since 1971)
amount_msat: 15000 * 10 * 1000, // 150k sats / year
expiry: 365 * 24 * 60 * 60 // 365 days
}
}
}

class PurpleInvoiceManager {
// nodeid: string, address: string, rune: string
constructor(api, nodeid, address, rune, ws_proxy_address) {
this.nodeid = nodeid
this.address = address
this.rune = rune
this.ws_proxy_address = ws_proxy_address
this.invoices_db = api.dbs.invoices
this.invoice_templates = getInvoiceTemplates()
this.checkout_objects = {}
this.api = api
}

// Connects and initializes this invoice manager
async connect_and_init() {
// Purge old invoices every 10 minutes
if (PURGE_OLD_INVOICES) {
this.purging_interval_timer = setInterval(() => this.purge_old_invoices(), 10 * 60 * 1000)
}
}

// Purge old invoices from the database
purge_old_invoices() {
for (const bolt11 of this.invoices_db.getKeys()) {
const invoice_request_info = this.invoices_db.get(bolt11)
const expiry = invoice_request_info.invoice_info.expires_at
// Delete invoices that expired more than 1 day ago
if (expiry < current_time() - 24 * 60 * 60) {
this.invoices_db.del(bolt11)
}
}
}

// Initiates a new checkout
new_checkout(template_name) {
const checkout_id = uuidv4()
const checkout_object = {
id: checkout_id,
verified_pubkey: null,
product_template_name: template_name,
invoice: null,
completed: false
}
this.checkout_objects[checkout_id] = checkout_object // Not persistent, but checkouts can be ephemeral
return checkout_object
}

// Verifies the user who is performing the checkout, and automatically generates an invoice for them to pay. Returns the updated checkout object.
async verify_checkout_object(checkout_id, authorized_pubkey) {
const checkout_object = deep_copy(this.checkout_objects[checkout_id]) // Deep copy to avoid intermittent issues when client requests the checkout object during modification
if (!checkout_object) {
return { request_error: "Invalid checkout_id" }
}
if (checkout_object.verified_pubkey) {
// Do not allow re-verifying a checkout for security and reasons, and to prevent undefined behavior.
return { request_error: "Checkout already verified" }
}
checkout_object.verified_pubkey = authorized_pubkey
const npub = nip19.npubEncode(authorized_pubkey)
// This is the only time we generate an invoice in the lifetime of a checkout.
// It is done during npub verification because it is the only authenticated step in the process.
// This prevents potential abuse or issues regenerating new invoices and causing the user to pay a stale invoice.
const invoice_request_info = await this.request_invoice(npub, checkout_object.product_template_name)
checkout_object.invoice = {
bolt11: invoice_request_info.invoice_info.bolt11, // The bolt11 invoice string for the user to pay
label: invoice_request_info.label, // The label of the invoice, used to monitor its status
connection_params: { // Connection params to connect to the LN node, to allow the frontend to monitor the invoice status directly
nodeid: this.nodeid,
address: this.address,
rune: this.rune,
ws_proxy_address: this.ws_proxy_address
}
}
// Update the checkout object since the state has changed. Changes are written all at once to avoid intermittent issues when client requests the checkout object during modification
this.checkout_objects[checkout_id] = checkout_object
return { checkout_object }
}

// Gets a deep copy of the checkout object
async get_checkout_object(checkout_id) {
if (!this.checkout_objects[checkout_id]) {
return null
}
const checkout_object = deep_copy(this.checkout_objects[checkout_id]) // Deep copy to avoid modifying the original object
if (!checkout_object) {
return null
}
return checkout_object
}

// Checks the status of the invoice associated with the given checkout object directly with the LN node, and handles successful payments.
async check_checkout_object_invoice(checkout_id) {
const checkout_object = await this.get_checkout_object(checkout_id)
if (checkout_object.invoice) {
checkout_object.invoice.paid = this.check_invoice_is_paid(checkout_object.invoice.label)
if (checkout_object.invoice.paid) {
this.handle_successful_payment(checkout_object.invoice.bolt11)
checkout_object.completed = true
this.checkout_objects[checkout_id] = checkout_object // Update the checkout object since the state has changed
}
}
return checkout_object
}

// Call this when the user wants to checkout a purple subscription and needs an invoice to pay
async request_invoice(npub, template_name) {
if (!this.invoice_templates[template_name]) {
throw new Error("Invalid template name")
}

const template = this.invoice_templates[template_name]
const description = template.description + "\nnpub: " + npub
const amount_msat = template.amount_msat
const label = `lnlink-purple-invoice-${uuidv4()}`
// TODO: In the future, we might want to set specific expiry times and mechanisms to avoid having the user pay a stale/unmonitored invoice, and to relieve the server from having to monitor invoices forever.
const invoice_info = await this.make_invoice(description, amount_msat, label)
const invoice_request_info = {
npub: npub,
label: label,
template_name: template_name,
invoice_info: invoice_info, // {payment_hash, expires_at, bolt11, payment_secret, created_index}
paid: false
}
const bolt11 = invoice_info.bolt11
this.invoices_db.put(bolt11, invoice_request_info)
return invoice_request_info
}

// This is called when an invoice is successfully paid
async handle_successful_payment(bolt11) {
const invoice_request_info = this.invoices_db.get(bolt11)
if (!invoice_request_info) {
throw new Error("Invalid bolt11 or not found")
}
invoice_request_info.paid = true
this.invoices_db.put(bolt11, invoice_request_info)
const npub = invoice_request_info.npub
const pubkey = nip19.decode(npub).data.toString('hex')
const result = bump_expiry(this.api, pubkey, this.invoice_templates[invoice_request_info.template_name].expiry)
if (!result.account) {
throw new Error("Could not bump expiry")
}
}

// Lower level function that generates the invoice based on the given parameters
async make_invoice(description, amount_msat, label) {
const params = { label, description, amount_msat }
return (await this.ln_rpc({ method: "invoice", params })).result
}

// Checks the status of an invoice once. Returns true if paid, false otherwise.
async check_invoice_is_paid(label) {
try {
const params = { label }
const res = await this.ln_rpc({ method: "waitinvoice", params })
return res.error ? false : true
}
catch {
return false
}
}

// Performs an RPC call to the LN node
async ln_rpc(args) {
const { method, params } = args
const ln = await LNSocket()
ln.genkey()
await ln.connect_and_init(this.nodeid, this.address)
return await ln.rpc({ rune: this.rune, method, params })
}

// Disconnects from the LN node and stops purging old invoices
async disconnect() {
if (this.purging_interval_timer) {
clearInterval(this.purging_interval_timer)
}
}
}

function deep_copy(obj) {
return JSON.parse(JSON.stringify(obj))
}

module.exports = { PurpleInvoiceManager, PURPLE_ONE_MONTH, PURPLE_ONE_YEAR }
110 changes: 110 additions & 0 deletions src/router_config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const verify_receipt = require('./app_store_receipt_verifier').verify_receipt
const bodyParser = require('body-parser')
const cors = require('cors');
const { required_nip98_auth, capture_raw_body, optional_nip98_auth } = require('./nip98_auth')
const { nip19 } = require('nostr-tools')
const { PURPLE_ONE_MONTH } = require('./invoicing')

function config_router(app) {
const router = app.router
Expand Down Expand Up @@ -95,6 +97,114 @@ function config_router(app) {
json_response(res, get_account_info_payload(account))
return
})

// MARK: Product and checkout routes

// Allows the website to get a list of options for the product
router.get('/products', (req, res) => {
json_response(res, app.invoice_manager.invoice_templates)
})

// Initiates a new checkout for a specific product
router.post('/ln-checkout', (req, res) => {
const body = req.body
const product_template_name = body.product_template_name
if (!product_template_name) {
invalid_request(res, 'Missing product_template_name')
return
}
if (!app.invoice_manager.invoice_templates[product_template_name]) {
invalid_request(res, 'Invalid product_template_name. Valid names are: ' + Object.keys(app.invoice_manager.invoice_templates).join(', '))
return
}
const checkout_object = app.invoice_manager.new_checkout(product_template_name)
json_response(res, checkout_object)
})

// Used to check the status of a checkout operation
// Note, this will not return the payment status of the invoice, only connection parameters from which the client can use to connect to the LN node
//
// Returns:
// {
// id: <UUID>
// product_template_name: <PRODUCT_TEMPLATE_NAME>
// verified_pubkey: <HEX_ENCODED_PUBKEY> | null
// invoice: {
// bolt11: <BOLT11_INVOICE>
// label: <LABEL_FOR_INVOICE_MONITORING>
// connection_params: {
// nodeid: <HEX_ENCODED_NODEID>
// address: <HOST:PORT>
// rune: <RUNE_STRING>
// },
// paid?: <BOOLEAN> // Only present when checkout is complete
// } | null,
// completed: <BOOLEAN> // Tells the client whether the checkout is complete (even if it was cancelled)
// }
router.get('/ln-checkout/:checkout_id', async (req, res) => {
const checkout_id = req.params.checkout_id
if (!checkout_id) {
error_response(res, 'Could not parse checkout_id')
return
}
const checkout_object = await app.invoice_manager.get_checkout_object(checkout_id)
if (!checkout_object) {
simple_response(res, 404)
return
}
json_response(res, checkout_object)
})

// Tells the server to check if the invoice has been paid, and proceed with the checkout if it has
// This route will return the checkout object with payment status appended to the invoice object
//
// Returns:
// {
// id: <UUID>
// product_template_name: <PRODUCT_TEMPLATE_NAME>
// verified_pubkey: <HEX_ENCODED_PUBKEY> | null
// invoice: {
// bolt11: <BOLT11_INVOICE>
// label: <LABEL_FOR_INVOICE_MONITORING>
// connection_params: {
// nodeid: <HEX_ENCODED_NODEID>
// address: <HOST:PORT>
// rune: <RUNE_STRING>
// },
// paid?: <BOOLEAN> // Only present when checkout is complete
// } | null,
// completed: <BOOLEAN> // Tells the client whether the checkout is complete (even if it was cancelled)
// }
router.post('/ln-checkout/:checkout_id/check-invoice', async (req, res) => {
const checkout_id = req.params.checkout_id
if (!checkout_id) {
error_response(res, 'Could not parse checkout_id')
return
}
const checkout_object = await app.invoice_manager.check_checkout_object_invoice(checkout_id)
json_response(res, checkout_object)
})

// Used by the Damus app to authenticate the user, and generate the final LN invoice
// This is necessary and useful to prevent several potential issues:
// - Prevents the user from purchasing without having a compatible Damus app installed
// - Prevents human errors when selecting the wrong npub
// - Prevents the user from purchasing for another user. Although gifting is a great feature, it needs to be implemented with more care to avoid confusion.
router.put('/ln-checkout/:checkout_id/verify', required_nip98_auth, async (req, res) => {
const checkout_id = req.params.checkout_id
if (!checkout_id) {
error_response(res, 'Could not parse checkout_id')
return
}
const response = await app.invoice_manager.verify_checkout_object(checkout_id, req.authorized_pubkey)
if (response.request_error) {
invalid_request(res, response.request_error)
}
if (response.checkout_object) {
json_response(res, response.checkout_object)
}
})

}

module.exports = { config_router }
Loading

0 comments on commit a861bbd

Please sign in to comment.