diff options
| author | Adam Soltys <[email protected]> | 2026-10-04 13:05:53 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-04 13:05:53 -0700 |
| commit | 1ff2623dbb43148267eac5cbe68c76d2c0639805 (patch) | |
| tree | 469260cac95741bbd319ee1e699ed56834e1abcc /lib | |
| parent | 41a5819f5d7c662a5a2884952e49a14464e83c41 (diff) | |
| download | coinos-server-1ff2623dbb43148267eac5cbe68c76d2c0639805.tar.gz coinos-server-1ff2623dbb43148267eac5cbe68c76d2c0639805.zip | |
v3 bridge: a migrated name's legacy receives are paid on to its v3 wallet
A user who moved their name to coinos v3 keeps their legacy account, and
old BOLT12 offers, bolt11 invoices and bitcoin/liquid deposit addresses
keep paying into it. Each confirmed receive of those kinds is now
forwarded as it lands to the name's v3 lightning address (registrar
invoice on the ASP node; the v3 forwarder delivers to the Ark mailbox
even with the wallet closed). Only new receives move; existing balances
stay. Routing cost only: the platform fee is waived (debit exempt), and
the direct channels to the ASP node quote 0. Proof required: the
registrar holds the name for the legacy account's own key, or for the
wallet its session named in a migration grant (/name granted flag).
Autowithdraw stands down for forwarded receives; failures retry with
backoff (v3fwd:retry, one process at a time) and otherwise stay put.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011bvzKaZosXGHkigcGcSzAR
Diffstat (limited to 'lib')
| -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"); + } +} |
