diff options
| -rw-r--r-- | lib/payments.ts | 16 | ||||
| -rw-r--r-- | lib/v3forward.ts | 133 |
2 files changed, 147 insertions, 2 deletions
diff --git a/lib/payments.ts b/lib/payments.ts index 84d2f25a..58dabc0d 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -40,6 +40,7 @@ import { t, } from "$lib/utils"; import { callWebhook } from "$lib/webhooks"; +import { maybeForwardToV3, retryV3Forwards } from "$lib/v3forward"; import changeid from "$lib/changeid"; import rpc from "@coinos/rpc"; import { bech32 } from "bech32"; @@ -111,6 +112,8 @@ export const debit = async ({ user, type = PaymentType.internal, rate = undefined, + // a v3 bridge forward (lib/v3forward.ts) costs routing only + exempt = false, maxTotal = undefined, }) => { amount = Number.parseInt(amount); @@ -245,7 +248,7 @@ export const debit = async ({ ? Math.round((amount + fee + tip) * feeRate) : 0; - if (aid !== uid) ourfee = 0; + if (aid !== uid || exempt) ourfee = 0; // NWC and other delegated spenders can provide the exact allowance remaining // for this call. Enforce it only after debit has reloaded the invoice and @@ -533,10 +536,17 @@ export const credit = async ({ return p; }; +const forwardSend = (o) => sendLightning({ ...o, exempt: true }); +// failed forwards retry with backoff; one process works the queue at a time +setInterval(() => retryV3Forwards(forwardSend).catch(() => {}), 60_000).unref(); + export const completePayment = async (inv, p, user) => { const { id, autowithdraw, threshold, reserve, destination, username } = user; let withdrawal; - if (p.confirmed) { + // A name that moved to coinos v3: the receive is paid on to the v3 wallet + // (lib/v3forward.ts), and legacy autowithdraw stands down for it. + const forwarded = await maybeForwardToV3(p, user, forwardSend); + if (p.confirmed && !forwarded) { if (autowithdraw) { try { const to = destination.trim(); @@ -945,6 +955,7 @@ export const sendLightning = async ({ memo = undefined, payerNote = undefined, maxTotal = undefined, + exempt = false, }) => { let p; @@ -1049,6 +1060,7 @@ export const sendLightning = async ({ user, type: PaymentType.lightning, maxTotal, + exempt, }); await db.sAdd("pending", pr); diff --git a/lib/v3forward.ts b/lib/v3forward.ts new file mode 100644 index 00000000..9a263acd --- /dev/null +++ b/lib/v3forward.ts @@ -0,0 +1,133 @@ +import { db, g } from "$lib/db"; +import { quoteRoutingFee } from "$lib/lnquote"; +import { l, shortError, warn } from "$lib/logging"; +import { PaymentType } from "$lib/types"; + +// v3 forwarding bridge. A user who migrated their name to coinos v3 still has +// their legacy account, and payments keep arriving there: old BOLT12 offers, +// old bolt11 invoices, old bitcoin/liquid deposit addresses. Each confirmed +// receive of that kind is paid on, as it lands, to the name's v3 lightning +// address (the registrar mints the invoice on the ASP's node, and its +// forwarder delivers to the wallet's Ark mailbox even while the wallet is +// closed). Only new receives move — a balance already sitting in the legacy +// account stays put — and the forward costs the user the routing fee only +// (none at all over the direct channels to the ASP's node). +// +// "Migrated" needs proof, since this moves money people sent to the LEGACY +// account: the registrar holds the name either for the legacy account's own +// nostr key (the identity-takeover rule) or for the v3 wallet the legacy +// account's own session named in a migration grant (`granted`). A name that +// merely exists on v3 is never forwarded to. + +const NAMES_URL = process.env.NAMES_URL || "https://names.coinos.io"; +const DOMAIN = "coinos.io"; +const TYPES = [PaymentType.lightning, PaymentType.bolt12, PaymentType.bitcoin, PaymentType.liquid]; +const RETRY_KEY = "v3fwd:retry"; // hash pid -> {uid, amount, tries, next} +const RETRY_MS = [60_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000, 12 * 3600_000]; + +const holderCache = new Map<string, { at: number; pk: string | null; granted: boolean }>(); +async function v3Holder(username: string): Promise<{ pk: string | null; granted: boolean }> { + const name = username.toLowerCase(); + const hit = holderCache.get(name); + // this runs on every legacy receive: a holder is re-checked each minute, + // a name nobody holds every ten (claims change on the order of days) + if (hit && Date.now() - hit.at < (hit.pk ? 60_000 : 600_000)) return hit; + const r = await fetch(`${NAMES_URL}/name/${encodeURIComponent(name)}?domain=${DOMAIN}`, + { signal: AbortSignal.timeout(2000) }); + if (!r.ok) throw new Error(`registrar said ${r.status}`); + const body: any = await r.json(); + const pk = body?.taken && typeof body.pubkey === "string" ? body.pubkey : null; + const hit2 = { at: Date.now(), pk, granted: !!pk && body.granted === true }; + holderCache.set(name, hit2); + return hit2; +} + +// The name's v3 address, if this legacy account has moved there. +export async function v3Destination(user): Promise<string | null> { + if (!user?.username || !user?.pubkey || user.username.length > 64) return null; + const { pk, granted } = await v3Holder(user.username); + return pk && (granted || pk === user.pubkey) ? user.username.toLowerCase() : null; +} + +async function invoiceFor(name: string, sats: number): Promise<string> { + const lnurl = `${NAMES_URL}/.well-known/lnurlp/${encodeURIComponent(name)}?domain=${DOMAIN}`; + const pr: any = await (await fetch(lnurl, { signal: AbortSignal.timeout(5000) })).json(); + if (pr?.tag !== "payRequest" || !pr.callback) throw new Error("no payRequest from registrar"); + const msat = sats * 1000; + if (msat < (pr.minSendable || 0)) throw Object.assign(new Error(`below the v3 minimum (${pr.minSendable / 1000} sats)`), { permanent: true }); + if (pr.maxSendable && msat > pr.maxSendable) throw Object.assign(new Error(`above the v3 maximum (${pr.maxSendable / 1000} sats)`), { permanent: true }); + const sep = pr.callback.includes("?") ? "&" : "?"; + const inv: any = await (await fetch(`${pr.callback}${sep}amount=${msat}`, { signal: AbortSignal.timeout(10_000) })).json(); + if (!inv?.pr) throw new Error(inv?.reason || "registrar returned no invoice"); + return inv.pr; +} + +// Pay `amount` sats of the user's legacy balance on to their v3 address, +// routing fee included in it. +async function forward({ user, amount, pid, send }) { + const name = await v3Destination(user); + if (!name) return null; + let pr = await invoiceFor(name, amount); + let { fee } = await quoteRoutingFee({ pr, amount }); + if (fee > 0) { + if (fee >= amount) throw Object.assign(new Error(`routing fee ${fee} exceeds ${amount}`), { permanent: true }); + pr = await invoiceFor(name, amount - fee); + ({ fee } = await quoteRoutingFee({ pr, amount: amount - fee })); + } + const w = await send({ user, pr, fee, memo: `Forwarded to ${name}@${DOMAIN} (coinos v3)` }); + l("v3 forward", user.username, amount, "fee", fee, "payment", pid, "->", w?.id); + return w; +} + +// Called from completePayment for every receive. Fire-and-forget: never +// blocks or fails the credit. Returns true when the receive was the bridge's +// (so legacy autowithdraw stands down for it). +export async function maybeForwardToV3(p, user, send): Promise<boolean> { + try { + if (!p?.confirmed || !TYPES.includes(p.type) || !(p.amount > 0)) return false; + if ((p.aid || p.uid) !== user?.id) return false; // sub-accounts stay put + if (!(await v3Destination(user))) return false; + // once per payment: completePayment runs twice for on-chain deposits + const fresh = await db.set(`v3fwd:claimed:${p.id}`, "1", { NX: true, EX: 30 * 86400 }); + if (!fresh) return true; + forward({ user, amount: p.amount, pid: p.id, send }).catch(async (e) => { + warn("v3 forward failed", user.username, p.amount, shortError(e?.message)); + if (!e?.permanent) await queueRetry(p.id, user.id, p.amount, 0); + }); + return true; + } catch (e: any) { + warn("v3 forward check failed", user?.username, shortError(e?.message)); + return false; + } +} + +async function queueRetry(pid: string, uid: string, amount: number, tries: number) { + if (tries >= RETRY_MS.length) { + warn("v3 forward gave up", uid, pid, amount, "— stays in the legacy balance"); + return; + } + await db.hSet(RETRY_KEY, pid, JSON.stringify({ uid, amount, tries, next: Date.now() + RETRY_MS[tries] })); +} + +// One process at a time works the retry queue (a short redis lock). +export async function retryV3Forwards(send) { + const lock = await db.set("v3fwd:lock", "1", { NX: true, EX: 120 }); + if (!lock) return; + try { + const all = await db.hGetAll(RETRY_KEY); + for (const [pid, raw] of Object.entries(all)) { + const e = JSON.parse(raw as string); + if (Date.now() < e.next) continue; + await db.hDel(RETRY_KEY, pid); + const user = await g(`user:${e.uid}`); + try { + if (!(await forward({ user, amount: e.amount, pid, send }))) continue; + } catch (err: any) { + warn("v3 forward retry failed", user?.username, e.amount, shortError(err?.message)); + if (!err?.permanent) await queueRetry(pid, e.uid, e.amount, e.tries + 1); + } + } + } finally { + await db.del("v3fwd:lock"); + } +} |
