-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
1 parent
1d0edbb
commit a861bbd
Showing
4 changed files
with
364 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.