diff options
| author | Adam Soltys <[email protected]> | 2026-02-04 23:13:33 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-06 09:00:09 -0800 |
| commit | 1ebc40afc14a75d7fe157b2c6c20ffea9dedb5eb (patch) | |
| tree | 960318969072afa63536e9429c27fa40ca2919d3 | |
| parent | c0481268da25eed1f25281508b5c4777d26c2b03 (diff) | |
| download | coinos-server-1ebc40afc14a75d7fe157b2c6c20ffea9dedb5eb.tar.gz coinos-server-1ebc40afc14a75d7fe157b2c6c20ffea9dedb5eb.zip | |
tigerbeetle
| -rwxr-xr-x | bun.lockb | bin | 234863 -> 235305 bytes | |||
| -rw-r--r-- | index.ts | 5 | ||||
| -rw-r--r-- | lib/db.ts | 67 | ||||
| -rw-r--r-- | lib/lightning.ts | 4 | ||||
| -rw-r--r-- | lib/migrate.ts | 58 | ||||
| -rw-r--r-- | lib/nwc.ts | 3 | ||||
| -rw-r--r-- | lib/payments.ts | 90 | ||||
| -rw-r--r-- | lib/register.ts | 5 | ||||
| -rw-r--r-- | lib/tb.ts | 572 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | routes/ecash.ts | 12 | ||||
| -rw-r--r-- | routes/payments.ts | 22 | ||||
| -rw-r--r-- | routes/users.ts | 39 | ||||
| -rwxr-xr-x | scripts/tb.sh | 165 |
14 files changed, 888 insertions, 155 deletions
| Binary files differ @@ -4,11 +4,12 @@ import { admin, auth, optional } from "$lib/auth"; import { fixBolt12, listenForLightning, replay } from "$lib/lightning"; import { startHealthCheck } from "$lib/health"; import { getLocations } from "$lib/locations"; -import { migrateAccounts } from "$lib/migrate"; +import { migrateAccounts, migrateBalancesToTB } from "$lib/migrate"; import nwc from "$lib/nwc"; import { check } from "$lib/payments"; import { getFx } from "$lib/rates"; import { sendHeartbeat } from "$lib/sockets"; +import { initTigerBeetle } from "$lib/tb"; import { startZmq } from "$lib/zmq"; import ecash from "$routes/ecash"; @@ -26,6 +27,7 @@ import square from "$routes/square"; import users from "$routes/users"; try { + await initTigerBeetle(); getLocations(); getFx(); nwc(); @@ -33,6 +35,7 @@ try { startHealthCheck(); startZmq(); migrateAccounts().then((n) => n && console.log(`Migrated ${n} accounts`)); + migrateBalancesToTB().then((n) => n && console.log(`Migrated ${n} balances to TB`)); } catch (e) { console.log(e); } @@ -1,75 +1,10 @@ import config from "$config"; import { err, warn } from "$lib/logging"; import { fail, sleep } from "$lib/utils"; -import { createClient, defineScript } from "redis"; - -const DEBIT = ` -local balanceKey = KEYS[1] -local creditKey = KEYS[2] -local insufficientString = KEYS[3]; -local amount = tonumber(ARGV[1]) -local tip = tonumber(ARGV[2]) -local fee = tonumber(ARGV[3]) -local ourfee = tonumber(ARGV[4]) -local frozen = tonumber(ARGV[5]) - -local balance = tonumber(redis.call('get', balanceKey) or '0') -local credit = tonumber(redis.call('get', creditKey) or '0') - -local covered = math.min(credit, ourfee) -ourfee = ourfee - covered - -if balance - frozen < amount + tip + fee + ourfee then - return {err = insufficientString .. ' ⚡️' .. balance - frozen .. ' / ' .. amount + tip + fee + ourfee} -end - -redis.call('decrby', creditKey, tostring(math.floor(covered))) -redis.call('decrby', balanceKey, tostring(math.floor(amount + tip + fee + ourfee))) - -return ourfee -`; - -const REVERSE = ` -local paymentKey = KEYS[1] -local balanceKey = KEYS[2] -local creditKey = KEYS[3] -local hashKey = KEYS[4] -local paymentsKey = KEYS[5] -local pid = KEYS[6] - -local total = tonumber(ARGV[1]) -local credit = tonumber(ARGV[2]) -local hash = ARGV[3] - -if redis.call('exists', paymentKey) == 1 then - redis.call('del', paymentKey) - redis.call('srem', 'pending', hash) - redis.call('incrby', balanceKey, total) - redis.call('incrby', creditKey, credit) - redis.call('del', hashKey) - redis.call('lrem', paymentsKey, 0, pid) - redis.call('lrem', 'payments', 0, pid) - return pid -else - error("Payment has already been reversed" .. paymentKey) -end -`; - -const debit = defineScript({ - NUMBER_OF_KEYS: 3, - SCRIPT: DEBIT, - transformArguments: (...args) => args.map((a) => a.toString()), -}); - -const reverse = defineScript({ - NUMBER_OF_KEYS: 6, - SCRIPT: REVERSE, - transformArguments: (...args) => args.map((a) => a.toString()), -}); +import { createClient } from "redis"; export const db = createClient({ url: config.db, - scripts: { debit, reverse }, socket: { reconnectStrategy: (retries) => Math.min(retries * 50, 5000), }, diff --git a/lib/lightning.ts b/lib/lightning.ts index 1f3b1665..e4c2e3cb 100644 --- a/lib/lightning.ts +++ b/lib/lightning.ts @@ -3,6 +3,7 @@ import ln, { lnListen, LightningUnavailableError } from "$lib/ln"; import { err, l, warn } from "$lib/logging"; import { handleZap } from "$lib/nostr"; import { credit } from "$lib/payments"; +import { tbSetBalance, getBalance } from "$lib/tb"; import { PaymentType } from "$lib/types"; import { getInvoice, getPayment, getUser } from "$lib/utils"; @@ -181,7 +182,8 @@ export const fixBolt12 = async (_, res) => { if (op) { db.del(`payment:${oid}`); db.del(`payment:${local_offer_id}`); - db.decrBy(`balance:${op.uid}`, op.amount); + const bal = await getBalance(op.uid); + await tbSetBalance(op.uid, bal - op.amount); } } } diff --git a/lib/migrate.ts b/lib/migrate.ts index 8fd4ec1a..fe804107 100644 --- a/lib/migrate.ts +++ b/lib/migrate.ts @@ -1,6 +1,13 @@ import { db, g, s } from "$lib/db"; import { findLastUsedIndex, parseDescriptor } from "$lib/esplora"; -import { warn } from "$lib/logging"; +import { l, warn } from "$lib/logging"; +import { + createBalanceAccount, + createCreditAccounts, + tbSetBalance, + tbSetPending, + tbSetCredit, +} from "$lib/tb"; async function migrate(id) { const k = id?.replace(/\s/g, "").toLowerCase(); @@ -49,4 +56,53 @@ export const migrateAccounts = async () => { return migrated; }; +export const migrateBalancesToTB = async () => { + const migrated = await db.get("tb:migrated"); + if (migrated) return 0; + + let count = 0; + + // Migrate balance:* keys + for await (const k of db.scanIterator({ MATCH: "balance:*" })) { + try { + const id = k.split(":")[1]; + const balance = Number.parseInt((await db.get(k)) || "0"); + await createBalanceAccount(id); + if (balance > 0) await tbSetBalance(id, balance); + count++; + } catch (e) { + warn("failed to migrate balance", k, e.message); + } + } + + // Migrate pending:* keys + for await (const k of db.scanIterator({ MATCH: "pending:*" })) { + try { + const id = k.split(":")[1]; + const pending = Number.parseInt((await db.get(k)) || "0"); + if (pending > 0) await tbSetPending(id, pending); + } catch (e) { + warn("failed to migrate pending", k, e.message); + } + } + + // Migrate credit:*:* keys + for await (const k of db.scanIterator({ MATCH: "credit:*:*" })) { + try { + const parts = k.split(":"); + const type = parts[1]; + const uid = parts[2]; + const amount = Number.parseInt((await db.get(k)) || "0"); + await createCreditAccounts(uid); + if (amount > 0) await tbSetCredit(uid, type, amount); + } catch (e) { + warn("failed to migrate credit", k, e.message); + } + } + + await db.set("tb:migrated", Date.now().toString()); + l(`Migrated ${count} balances to TigerBeetle`); + return count; +}; + export default migrate; @@ -11,6 +11,7 @@ import { serverSecret2, } from "$lib/nostr"; import { sendInternal, sendKeysend, sendLightning } from "$lib/payments"; +import { getBalance } from "$lib/tb"; import { fail, getInvoice, sleep } from "$lib/utils"; import rpc from "@coinos/rpc"; import { hexToBytes } from "@noble/hashes/utils"; @@ -327,7 +328,7 @@ const handle = (method, params, ev, app, user) => }, async get_balance() { - let balance = await g(`balance:${user.id}`); + let balance = await getBalance(user.id); balance *= 1000; return result({ balance }); }, diff --git a/lib/payments.ts b/lib/payments.ts index 4fddcba8..011807d5 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -22,6 +22,17 @@ import { notify, nwcNotify } from "$lib/notifications"; import { emit } from "$lib/sockets"; import { squarePayment } from "$lib/square"; import { + getBalance, + getCredit, + tbConfirm, + tbCredit, + tbDebit, + tbRefund, + tbReverse, + tbSetBalance, + tbSetPending, +} from "$lib/tb"; +import { SATS, btc, fail, @@ -137,17 +148,18 @@ export const debit = async ({ if (aid !== uid) ourfee = 0; const frozenBalance = - !blacklisted || whitelisted ? 0 : await g(`balance:${uid}`); + !blacklisted || whitelisted ? 0 : await getBalance(uid); - ourfee = await db.debit( - `balance:${aid}`, - `credit:${creditType}:${aid !== uid ? 0 : uid}`, - t(user).insufficientFunds, + ourfee = await tbDebit( + aid, + uid, + creditType, amount || 0, tip || 0, fee || 0, ourfee || 0, frozenBalance || 0, + t(user).insufficientFunds, ); if (ourfee.err) fail(ourfee.err); @@ -275,24 +287,17 @@ export const credit = async ({ await s(`payment:${hash}`, id); } - const m = await db.multi(); - let creditType = type; if (creditType === PaymentType.bolt12) creditType = PaymentType.lightning; - if ( - [PaymentType.bitcoin, PaymentType.liquid, PaymentType.lightning].includes( - creditType, - ) - ) - m.incrBy( - `credit:${creditType}:${uid}`, - Math.round(amount * config.fee[creditType]), - ); + const isPending = balanceKey === "pending"; + + await tbCredit(aid || uid, uid, creditType, amount, isPending); - m.set(`invoice:${inv.id}`, JSON.stringify(inv)) + await db + .multi() + .set(`invoice:${inv.id}`, JSON.stringify(inv)) .set(`payment:${p.id}`, JSON.stringify(p)) .lPush(`${aid || uid}:payments`, p.id) - .incrBy(`${balanceKey}:${aid || uid}`, amount) .set(`${aid || uid}:payments:last`, p.created) .exec(); @@ -313,7 +318,7 @@ export const completePayment = async (inv, p, user) => { if (autowithdraw) { try { const to = destination.trim(); - const balance = await g(`balance:${id}`); + const balance = await getBalance(id); const amount = balance - reserve; if (balance > threshold) { l("initiating autowithdrawal", amount, to, balance, threshold); @@ -349,8 +354,7 @@ const confirmWatchedIncoming = async (address, existing) => { await s(`invoice:${inv.id}`, inv); } await s(`payment:${existing.id}`, existing); - await db.decrBy(`pending:${existing.aid || existing.uid}`, existing.amount); - await db.incrBy(`balance:${existing.aid || existing.uid}`, existing.amount); + await tbConfirm(existing.aid || existing.uid, existing.amount); const user = await getUser(existing.uid); if (inv) await completePayment(inv, existing, user); await db.sRem("watching", address); @@ -872,7 +876,7 @@ const buildNonCustodial = async ({ }; }); - const balance = await g(`balance:${aid}`); + const balance = await getBalance(aid); let ourfee = 0; // Non-custodial accounts don't pay platform fee const outputs = [{ address, amount: BigInt(amount) }]; @@ -999,10 +1003,10 @@ export const build = async ({ else throw e; } - const balance = await g(`balance:${aid}`); + const balance = await getBalance(aid); let ourfee = Math.round(amount * config.fee[type]); - const credit = await g(`credit:${type}:${aid}`); - const covered = Math.min(credit, ourfee); + const creditBal = await getCredit(aid, type); + const covered = Math.min(creditBal, ourfee); ourfee -= covered; if (subtract || amount + fee + ourfee > balance) { @@ -1232,11 +1236,8 @@ export const importAccountHistory = async (account) => { .exec(); } - await db - .multi() - .set(`balance:${id}`, total) - .set(`pending:${id}`, 0) - .exec(); + await tbSetBalance(id, total); + await tbSetPending(id, 0); account.importedAt = account.importedAt || Date.now(); await s(`account:${id}`, account); @@ -1295,7 +1296,7 @@ const finalize = async (r, p) => { await s(`payment:${p.id}`, p); l("refunding fee", maxfee, p.fee, maxfee - p.fee, p.ref); - await db.incrBy(`balance:${p.uid}`, maxfee - p.fee); + await tbRefund(p.uid, maxfee - p.fee); } return p; @@ -1310,17 +1311,22 @@ const reverse = async (p) => { l("reversing", p.id, p.amount, p.fee, total, ourfee, credit); - await db.reverse( - `payment:${p.id}`, - `balance:${p.uid}`, - `credit:${PaymentType.lightning}:${p.uid}`, - `payment:${p.hash}`, - `${p.uid}:payments`, - p.id, - total, - credit, - p.hash, - ); + // Check payment still exists before reversing + const exists = await db.exists(`payment:${p.id}`); + if (!exists) throw new Error("Payment has already been reversed"); + + // TB: restore balance + credits + await tbReverse(p.uid, total, credit); + + // Redis: clean up payment records + await db + .multi() + .del(`payment:${p.id}`) + .sRem("pending", p.hash) + .del(`payment:${p.hash}`) + .lRem(`${p.uid}:payments`, 0, p.id) + .lRem("payments", 0, p.id) + .exec(); warn("reversed", p.id); }; diff --git a/lib/register.ts b/lib/register.ts index a74ea640..acffa9f2 100644 --- a/lib/register.ts +++ b/lib/register.ts @@ -2,6 +2,7 @@ import config from "$config"; import countries from "$lib/countries"; import { db, s } from "$lib/db"; import { l, warn } from "$lib/logging"; +import { createBalanceAccount, createCreditAccounts } from "$lib/tb"; import { fail } from "$lib/utils"; import { bytesToHex, randomBytes } from "@noble/hashes/utils"; import { got } from "got"; @@ -88,11 +89,13 @@ export default async (user, ip) => { await s(`app:${app.pubkey}`, app); await db.sAdd(`${id}:apps`, app.pubkey); + await createBalanceAccount(id); + await createCreditAccounts(id); + db.multi() .set(`user:${id}`, JSON.stringify(user)) .set(`user:${username}`, id) .set(`user:${pubkey}`, id) - .set(`balance:${id}`, 0) .set(`account:${id}`, account) .set(`${pubkey}:follows:n`, 0) .set(`${pubkey}:followers:n`, 0) diff --git a/lib/tb.ts b/lib/tb.ts new file mode 100644 index 00000000..9293422e --- /dev/null +++ b/lib/tb.ts @@ -0,0 +1,572 @@ +import config from "$config"; +import { warn } from "$lib/logging"; +import { + createClient, + CreateAccountError, + AccountFlags, + TransferFlags, +} from "tigerbeetle-node"; + +let client: any; + +// Ledger IDs +const LEDGER_SATS = 1; +const LEDGER_CREDIT_BTC = 2; +const LEDGER_CREDIT_LN = 3; +const LEDGER_CREDIT_LQ = 4; + +const creditLedger: Record<string, number> = { + bitcoin: LEDGER_CREDIT_BTC, + lightning: LEDGER_CREDIT_LN, + liquid: LEDGER_CREDIT_LQ, +}; + +// House account IDs match their ledger +const HOUSE_SATS = 1n; +const HOUSE_BTC = 2n; +const HOUSE_LN = 3n; +const HOUSE_LQ = 4n; + +const houseCreditAccount: Record<string, bigint> = { + bitcoin: HOUSE_BTC, + lightning: HOUSE_LN, + liquid: HOUSE_LQ, +}; + +// Convert UUID string to BigInt +function uuidToBigInt(uuid: string): bigint { + const hex = uuid.replace(/-/g, ""); + return BigInt(`0x${hex}`); +} + +// ID derivation +function balanceId(aid: string): bigint { + return uuidToBigInt(aid); +} + +function pendingId(aid: string): bigint { + return uuidToBigInt(aid) ^ (5n << 64n); +} + +function creditId(uid: string, type: string): bigint { + const ledger = BigInt(creditLedger[type] || 0); + return uuidToBigInt(uid) ^ (ledger << 64n); +} + +function u128(n: bigint): bigint { + return n & ((1n << 128n) - 1n); +} + +async function resolveAddresses(addresses: string[]): Promise<string[]> { + const { lookup } = await import("node:dns/promises"); + const resolved: string[] = []; + for (const addr of addresses) { + const [host, port] = addr.split(":"); + try { + const { address: ip } = await lookup(host); + resolved.push(`${ip}:${port}`); + } catch { + resolved.push(addr); + } + } + return resolved; +} + +export async function initTigerBeetle() { + const { cluster_id, replica_addresses } = config.tigerbeetle; + const resolved = await resolveAddresses(replica_addresses); + client = createClient({ cluster_id, replica_addresses: resolved }); + + // Create house accounts (no flags = no constraints) + const houseAccounts = [ + { id: u128(HOUSE_SATS), ledger: LEDGER_SATS, code: 1 }, + { id: u128(HOUSE_BTC), ledger: LEDGER_CREDIT_BTC, code: 1 }, + { id: u128(HOUSE_LN), ledger: LEDGER_CREDIT_LN, code: 1 }, + { id: u128(HOUSE_LQ), ledger: LEDGER_CREDIT_LQ, code: 1 }, + ].map((a) => ({ + id: a.id, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: a.ledger, + code: a.code, + flags: 0, + timestamp: 0n, + })); + + const results = await client.createAccounts(houseAccounts); + for (const r of results) { + if (r.result !== CreateAccountError.exists) { + warn("TB house account creation:", r.index, r.result); + } + } +} + +export async function createBalanceAccount(aid: string) { + const accounts = [ + { + id: u128(balanceId(aid)), + ledger: LEDGER_SATS, + code: 1, + flags: AccountFlags.debits_must_not_exceed_credits, + }, + { + id: u128(pendingId(aid)), + ledger: LEDGER_SATS, + code: 1, + flags: AccountFlags.debits_must_not_exceed_credits, + }, + ].map((a) => ({ + ...a, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + timestamp: 0n, + })); + + const results = await client.createAccounts(accounts); + for (const r of results) { + if (r.result !== CreateAccountError.exists) { + warn("TB balance account creation:", r.index, r.result); + } + } +} + +export async function createCreditAccounts(uid: string) { + const accounts = ["bitcoin", "lightning", "liquid"].map((type) => ({ + id: u128(creditId(uid, type)), + ledger: creditLedger[type], + code: 1, + flags: AccountFlags.debits_must_not_exceed_credits, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + timestamp: 0n, + })); + + const results = await client.createAccounts(accounts); + for (const r of results) { + if (r.result !== CreateAccountError.exists) { + warn("TB credit account creation:", r.index, r.result); + } + } +} + +async function getAccount(id: bigint) { + const accounts = await client.lookupAccounts([u128(id)]); + if (accounts.length === 0) return null; + return accounts[0]; +} + +function accountBalance(account: any): number { + if (!account) return 0; + return Number(account.credits_posted - account.debits_posted); +} + +export async function getBalance(aid: string): Promise<number> { + const account = await getAccount(balanceId(aid)); + return accountBalance(account); +} + +export async function getPending(aid: string): Promise<number> { + const account = await getAccount(pendingId(aid)); + return accountBalance(account); +} + +export async function getCredit(uid: string, type: string): Promise<number> { + const account = await getAccount(creditId(uid, type)); + return accountBalance(account); +} + +let transferIdCounter = BigInt(Date.now()) << 64n; +function nextTransferId(): bigint { + transferIdCounter += 1n; + return u128(transferIdCounter); +} + +export async function tbDebit( + aid: string, + uid: string, + creditType: string, + amount: number, + tip: number, + fee: number, + ourfee: number, + frozen: number, + errMsg: string, +): Promise<number> { + // Look up credit balance + let covered = 0; + if (ourfee > 0 && creditType && creditLedger[creditType]) { + const creditBal = await getCredit( + uid === aid ? uid : "00000000-0000-0000-0000-000000000000", + creditType, + ); + covered = Math.min(creditBal, ourfee); + ourfee -= covered; + } + + const totalDebit = amount + tip + fee + ourfee; + + // Check balance is sufficient (accounting for frozen funds) + const currentBalance = await getBalance(aid); + if (currentBalance - frozen < totalDebit) { + return { + err: `${errMsg} ⚡️${currentBalance - frozen} / ${totalDebit}`, + } as any; + } + + // Build linked transfers + const transfers: any[] = []; + + // 1. Consume credits (user credit → house credit) if any + if (covered > 0) { + const creditUserAcctId = creditId( + uid === aid ? uid : "00000000-0000-0000-0000-000000000000", + creditType, + ); + transfers.push({ + id: nextTransferId(), + debit_account_id: u128(creditUserAcctId), + credit_account_id: u128(houseCreditAccount[creditType]), + amount: BigInt(covered), + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: creditLedger[creditType], + code: 1, + flags: 0, // linked flag set below + timestamp: 0n, + }); + } + + // 2. Debit balance (user → house) + transfers.push({ + id: nextTransferId(), + debit_account_id: u128(balanceId(aid)), + credit_account_id: u128(HOUSE_SATS), + amount: BigInt(totalDebit), + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: LEDGER_SATS, + code: 1, + flags: 0, + timestamp: 0n, + }); + + // Link all but the last transfer + const linked = TransferFlags.linked; + + for (let i = 0; i < transfers.length - 1; i++) { + transfers[i].flags |= linked; + } + + const results = await client.createTransfers(transfers); + if (results.length > 0) { + return { + err: `${errMsg} ⚡️${currentBalance - frozen} / ${totalDebit}`, + } as any; + } + + return ourfee; +} + +export async function tbCredit( + aid: string, + uid: string, + creditType: string, + amount: number, + isPending: boolean, +) { + const transfers: any[] = []; + + // Transfer house → user balance (or house → pending) + const targetAccount = isPending ? pendingId(aid) : balanceId(aid); + transfers.push({ + id: nextTransferId(), + debit_account_id: u128(HOUSE_SATS), + credit_account_id: u128(targetAccount), + amount: BigInt(amount), + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: LEDGER_SATS, + code: 1, + flags: 0, + timestamp: 0n, + }); + + // Add fee credit if applicable + if (creditType && creditLedger[creditType] && config.fee[creditType]) { + const creditAmount = Math.round(amount * config.fee[creditType]); + if (creditAmount > 0) { + transfers.push({ + id: nextTransferId(), + debit_account_id: u128(houseCreditAccount[creditType]), + credit_account_id: u128(creditId(uid, creditType)), + amount: BigInt(creditAmount), + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: creditLedger[creditType], + code: 1, + flags: 0, + timestamp: 0n, + }); + } + } + + const results = await client.createTransfers(transfers); + for (const r of results) { + warn("TB credit transfer error:", r.index, r.result); + } +} + +export async function tbConfirm(aid: string, amount: number) { + // Transfer pending → balance (via house as intermediary) + const linked = TransferFlags.linked; + + const transfers = [ + { + id: nextTransferId(), + debit_account_id: u128(pendingId(aid)), + credit_account_id: u128(HOUSE_SATS), + amount: BigInt(amount), + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: LEDGER_SATS, + code: 1, + flags: linked, + timestamp: 0n, + }, + { + id: nextTransferId(), + debit_account_id: u128(HOUSE_SATS), + credit_account_id: u128(balanceId(aid)), + amount: BigInt(amount), + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: LEDGER_SATS, + code: 1, + flags: 0, + timestamp: 0n, + }, + ]; + + const results = await client.createTransfers(transfers); + for (const r of results) { + warn("TB confirm transfer error:", r.index, r.result); + } +} + +export async function tbReverse( + uid: string, + total: number, + creditAmount: number, +) { + const transfers: any[] = []; + const linked = TransferFlags.linked; + + // House → user balance + transfers.push({ + id: nextTransferId(), + debit_account_id: u128(HOUSE_SATS), + credit_account_id: u128(balanceId(uid)), + amount: BigInt(total), + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: LEDGER_SATS, + code: 1, + flags: creditAmount > 0 ? linked : 0, + timestamp: 0n, + }); + + // House credit → user credit (lightning) + if (creditAmount > 0) { + transfers.push({ + id: nextTransferId(), + debit_account_id: u128(HOUSE_LN), + credit_account_id: u128(creditId(uid, "lightning")), + amount: BigInt(creditAmount), + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: LEDGER_CREDIT_LN, + code: 1, + flags: 0, + timestamp: 0n, + }); + } + + const results = await client.createTransfers(transfers); + for (const r of results) { + warn("TB reverse transfer error:", r.index, r.result); + } +} + +export async function tbRefund(uid: string, amount: number) { + if (amount <= 0) return; + + const transfers = [ + { + id: nextTransferId(), + debit_account_id: u128(HOUSE_SATS), + credit_account_id: u128(balanceId(uid)), + amount: BigInt(amount), + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: LEDGER_SATS, + code: 1, + flags: 0, + timestamp: 0n, + }, + ]; + + const results = await client.createTransfers(transfers); + for (const r of results) { + warn("TB refund transfer error:", r.index, r.result); + } +} + +export async function tbSetBalance(aid: string, target: number) { + const current = await getBalance(aid); + const diff = target - current; + if (diff === 0) return; + + const transfers = [ + { + id: nextTransferId(), + debit_account_id: diff > 0 ? u128(HOUSE_SATS) : u128(balanceId(aid)), + credit_account_id: diff > 0 ? u128(balanceId(aid)) : u128(HOUSE_SATS), + amount: BigInt(Math.abs(diff)), + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: LEDGER_SATS, + code: 1, + flags: 0, + timestamp: 0n, + }, + ]; + + const results = await client.createTransfers(transfers); + for (const r of results) { + warn("TB setBalance transfer error:", r.index, r.result); + } +} + +export async function tbSetPending(aid: string, target: number) { + const current = await getPending(aid); + const diff = target - current; + if (diff === 0) return; + + const transfers = [ + { + id: nextTransferId(), + debit_account_id: diff > 0 ? u128(HOUSE_SATS) : u128(pendingId(aid)), + credit_account_id: diff > 0 ? u128(pendingId(aid)) : u128(HOUSE_SATS), + amount: BigInt(Math.abs(diff)), + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: LEDGER_SATS, + code: 1, + flags: 0, + timestamp: 0n, + }, + ]; + + const results = await client.createTransfers(transfers); + for (const r of results) { + warn("TB setPending transfer error:", r.index, r.result); + } +} + +export async function tbSetCredit(uid: string, type: string, target: number) { + const current = await getCredit(uid, type); + const diff = target - current; + if (diff === 0) return; + + const ledger = creditLedger[type]; + if (!ledger) return; + + const transfers = [ + { + id: nextTransferId(), + debit_account_id: diff > 0 ? u128(houseCreditAccount[type]) : u128(creditId(uid, type)), + credit_account_id: diff > 0 ? u128(creditId(uid, type)) : u128(houseCreditAccount[type]), + amount: BigInt(Math.abs(diff)), + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger, + code: 1, + flags: 0, + timestamp: 0n, + }, + ]; + + const results = await client.createTransfers(transfers); + for (const r of results) { + warn("TB setCredit transfer error:", r.index, r.result); + } +} + +// Debit from a fund:* key (Redis only, no credits) +export async function fundDebit( + fundKey: string, + amount: number, + errMsg: string, +): Promise<any> { + const { db } = await import("$lib/db"); + const bal = Number.parseInt((await db.get(fundKey)) || "0"); + if (bal < amount) { + return { err: `${errMsg} ⚡️${bal} / ${amount}` }; + } + await db.decrBy(fundKey, amount); + return 0; +} diff --git a/package.json b/package.json index 42207ad7..834bc053 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "sharp": "^0.33.5", "square": "^43.0.2", "strfry-rpc": "^0.0.1", + "tigerbeetle-node": "^0.16.70", "uuid": "^8.3.2", "web-push": "^3.6.7", "ws": "^7.5.10" diff --git a/routes/ecash.ts b/routes/ecash.ts index b7ce050e..57cc49c1 100644 --- a/routes/ecash.ts +++ b/routes/ecash.ts @@ -3,6 +3,7 @@ import { check, claim, get, mint } from "$lib/ecash"; import { err, l } from "$lib/logging"; import { credit, debit } from "$lib/payments"; import { emit } from "$lib/sockets"; +import { tbDebit } from "$lib/tb"; import { bail, fail, getInvoice } from "$lib/utils"; import { getEncodedToken } from "@cashu/cashu-ts"; import { v4 } from "uuid"; @@ -103,15 +104,16 @@ export default { const { lightning: type } = PaymentType; if (user.username !== "mint") fail("unauthorized"); const { id: uid, currency } = user; - const ourfee = await db.debit( - `balance:${uid}`, - `credit:${type}:${uid}`, - "Insufficient funds", + const ourfee = await tbDebit( + uid, + uid, + type, amount || 0, 0, 0, 0, - 0 + 0, + "Insufficient funds", ); const rates = await g("rates"); diff --git a/routes/payments.ts b/routes/payments.ts index f9225b87..5e31d8b8 100644 --- a/routes/payments.ts +++ b/routes/payments.ts @@ -21,6 +21,7 @@ import { sendOnchain, } from "$lib/payments"; import { emit } from "$lib/sockets"; +import { fundDebit, getBalance, getCredit, tbConfirm } from "$lib/tb"; import { PaymentType } from "$lib/types"; import { SATS, @@ -45,7 +46,7 @@ export default { const { body, user } = req; let { amount, hash, fee, fund, memo, payreq, aid } = body; - const balance = await g(`balance:${user.id}`); + const balance = await getBalance(user.id); try { if (typeof amount !== "undefined") { @@ -247,8 +248,8 @@ export default { const amount = Math.round(amount_msat / 1000); let ourfee = Math.round(amount * config.fee[PaymentType.lightning]); - const credit = await g(`credit:lightning:${user.id}`); - const covered = Math.min(credit, ourfee) || 0; + const creditBal = await getCredit(user.id, "lightning"); + const covered = Math.min(creditBal, ourfee) || 0; ourfee -= covered; res.send({ alias, amount, ourfee }); @@ -364,16 +365,7 @@ export default { const managers = await db.sMembers(`fund:${id}:managers`); if (managers.length && !managers.includes(user.id)) fail("Unauthorized"); - const result: any = await db.debit( - `fund:${id}`, - "", - "Insufficient funds", - amount, - 0, - 0, - 0, - 0, - ); + const result: any = await fundDebit(`fund:${id}`, amount, "Insufficient funds"); if (result.err) fail(result.err); const payment = await credit({ @@ -525,12 +517,12 @@ export default { l("confirming", id, p.id, p.amount); + await tbConfirm(p.aid || p.uid, p.amount); + await db .multi() .set(`invoice:${iid}`, JSON.stringify(invoice)) .set(`payment:${p.id}`, JSON.stringify(p)) - .decrBy(`pending:${p.aid || p.uid}`, p.amount) - .incrBy(`balance:${p.aid || p.uid}`, p.amount) .exec(); const user = await g(`user:${p.uid}`); diff --git a/routes/users.ts b/routes/users.ts index 3d9602cf..0a3cebeb 100644 --- a/routes/users.ts +++ b/routes/users.ts @@ -21,6 +21,7 @@ import { authenticator } from "otplib"; import { v4 } from "uuid"; import { PaymentType } from "$lib/types"; +import { createBalanceAccount, getBalance, getCredit } from "$lib/tb"; import { importAccountHistory } from "$lib/payments"; import type { ProfilePointer } from "nostr-tools/nip19"; @@ -83,7 +84,7 @@ export default { async me(req, res) { const { user } = req; try { - user.balance = await g(`balance:${user.id}`); + user.balance = await getBalance(user.id); user.locked = await ga(`balance:${user.id}`); if (user.locked) { @@ -116,16 +117,13 @@ export default { const users = []; - for await (const k of db.scanIterator({ MATCH: "balance:*" })) { - const uid = k.split(":")[1]; - const user = await getUser(uid); + for await (const k of db.scanIterator({ MATCH: "user:*" })) { + const val = await g(k); + if (!val || typeof val !== "object" || !val.id || !val.username) continue; + const uid = val.id; + const u = val; - if (!user) { - await db.del(`balance:${uid}`); - continue; - } - - user.balance = await g(k); + u.balance = await getBalance(uid); const payments = await db.lRange(`${uid}:payments`, 0, -1); @@ -139,8 +137,8 @@ export default { else total += p.tip || 0; } - user.expected = total; - users.push(user); + u.expected = total; + users.push(u); } res.send(users); @@ -822,7 +820,7 @@ export default { if (pos == null) fail("account not found"); const account = await g(`account:${id}`); - if (account) account.balance = await g(`balance:${id}`); + if (account) account.balance = await getBalance(id); res.send(account); }, @@ -837,7 +835,7 @@ export default { if (account.seed && account.pubkey && !account.importedAt) { await importAccountHistory(account); } - account.balance = await g(`balance:${id}`); + account.balance = await getBalance(id); accounts.push(account); } @@ -890,13 +888,13 @@ export default { importedAt: seed ? Date.now() : undefined, }; + await createBalanceAccount(id); + // ARK accounts don't need Bitcoin Core wallet setup if (type === "ark") { await db .multi() .set(`account:${id}`, JSON.stringify(account)) - .set(`balance:${id}`, 0) - .set(`pending:${id}`, 0) .lPush(`${user.id}:accounts`, id) .exec(); @@ -939,8 +937,6 @@ export default { await db .multi() .set(`account:${id}`, JSON.stringify(account)) - .set(`balance:${id}`, 0) - .set(`pending:${id}`, 0) .lPush(`${user.id}:accounts`, id) .exec(); @@ -988,7 +984,6 @@ export default { .multi() .lRem(`${uid}:accounts`, 1, id) .del(`account:${id}`) - .del(`balance:${id}`) .del(`${id}:payments`) .exec(); @@ -1161,9 +1156,9 @@ export default { async credits(req, res) { const { id } = req.user; - const bitcoin = await g(`credit:bitcoin:${id}`); - const lightning = await g(`credit:lightning:${id}`); - const liquid = await g(`credit:liquid:${id}`); + const bitcoin = await getCredit(id, "bitcoin"); + const lightning = await getCredit(id, "lightning"); + const liquid = await getCredit(id, "liquid"); res.send({ bitcoin, lightning, liquid }); }, diff --git a/scripts/tb.sh b/scripts/tb.sh new file mode 100755 index 00000000..03daead8 --- /dev/null +++ b/scripts/tb.sh @@ -0,0 +1,165 @@ +#!/bin/bash +# TigerBeetle query utilities for coinos-server +# Source this file: source scripts/tb.sh + +# Get user UUID from Redis by username +_user_id() { + local username="$1" + docker exec db valkey-cli GET "user:$username" | tr -d '"' | while read key; do + if [[ "$key" =~ ^[0-9a-f-]{36}$ ]]; then + docker exec db valkey-cli GET "user:$key" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['id'])" + else + echo "$key" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['id'])" 2>/dev/null || echo "$key" + fi + done +} + +# Query TB accounts via bun in app container +# Args: space-separated list of bigint IDs (as decimal strings) +# Returns: id credits_posted debits_posted balance (one line per account) +_tb_lookup() { + local ids="$1" + docker exec app bun -e " + const { createClient } = require('tigerbeetle-node'); + const { lookup } = require('node:dns/promises'); + (async () => { + const { address: ip } = await lookup('tb'); + const c = createClient({ cluster_id: 0n, replica_addresses: [\`\${ip}:3000\`] }); + const ids = '${ids}'.split(',').map(s => BigInt(s.trim())); + const accs = await c.lookupAccounts(ids); + const found = new Map(accs.map(a => [a.id.toString(), a])); + for (const id of ids) { + const a = found.get(id.toString()); + if (a) { + const bal = a.credits_posted - a.debits_posted; + console.log(id.toString() + ' ' + a.credits_posted.toString() + ' ' + a.debits_posted.toString() + ' ' + bal.toString()); + } else { + console.log(id.toString() + ' 0 0 0'); + } + } + c.destroy(); + })(); + " 2>/dev/null +} + +# Convert UUID to BigInt decimal string +_uuid2int() { + python3 -c "print(int('${1//-/}', 16))" +} + +# Derive all TB account IDs for a UUID +_derive_ids() { + local uid="$1" + python3 -c " +uid = int('${uid//-/}', 16) +print(uid) # balance +print(uid ^ (5 << 64)) # pending +print(uid ^ (2 << 64)) # btc credit +print(uid ^ (3 << 64)) # ln credit +print(uid ^ (4 << 64)) # lq credit +" +} + +# Get balance for a username +tb-balance() { + if [ -z "$1" ]; then echo "Usage: tb-balance <username>"; return 1; fi + local uid=$(_user_id "$1") + if [ -z "$uid" ]; then echo "User not found: $1"; return 1; fi + local account_id=$(_uuid2int "$uid") + local result=$(_tb_lookup "$account_id") + local bal=$(echo "$result" | awk '{print $4}') + echo "$1: $bal sats" +} + +# Get pending balance for a username +tb-pending() { + if [ -z "$1" ]; then echo "Usage: tb-pending <username>"; return 1; fi + local uid=$(_user_id "$1") + if [ -z "$uid" ]; then echo "User not found: $1"; return 1; fi + local pending_id=$(python3 -c "print(int('${uid//-/}', 16) ^ (5 << 64))") + local result=$(_tb_lookup "$pending_id") + local bal=$(echo "$result" | awk '{print $4}') + echo "$1: $bal sats pending" +} + +# Get fee credits for a username +tb-credits() { + if [ -z "$1" ]; then echo "Usage: tb-credits <username>"; return 1; fi + local uid=$(_user_id "$1") + if [ -z "$uid" ]; then echo "User not found: $1"; return 1; fi + local ids + IFS=$'\n' read -d '' -ra ids < <(_derive_ids "$uid") + local result=$(_tb_lookup "${ids[2]},${ids[3]},${ids[4]}") + local btc=$(echo "$result" | sed -n '1p' | awk '{print $4}') + local ln=$(echo "$result" | sed -n '2p' | awk '{print $4}') + local lq=$(echo "$result" | sed -n '3p' | awk '{print $4}') + echo "$1 credits:" + echo " bitcoin: $btc" + echo " lightning: $ln" + echo " liquid: $lq" +} + +# Full summary for a username +tb-user() { + if [ -z "$1" ]; then echo "Usage: tb-user <username>"; return 1; fi + local uid=$(_user_id "$1") + if [ -z "$uid" ]; then echo "User not found: $1"; return 1; fi + local ids + IFS=$'\n' read -d '' -ra ids < <(_derive_ids "$uid") + local result=$(_tb_lookup "${ids[0]},${ids[1]},${ids[2]},${ids[3]},${ids[4]}") + local bal=$(echo "$result" | sed -n '1p' | awk '{print $4}') + local pending=$(echo "$result" | sed -n '2p' | awk '{print $4}') + local btc=$(echo "$result" | sed -n '3p' | awk '{print $4}') + local ln=$(echo "$result" | sed -n '4p' | awk '{print $4}') + local lq=$(echo "$result" | sed -n '5p' | awk '{print $4}') + echo "$1 ($uid)" + echo " balance: $bal sats" + echo " pending: $pending sats" + echo " credits:" + echo " bitcoin: $btc" + echo " lightning: $ln" + echo " liquid: $lq" +} + +# Show house account balances +tb-house() { + local result=$(_tb_lookup "1,2,3,4") + local sats=$(echo "$result" | sed -n '1p' | awk '{print $4}') + local btc=$(echo "$result" | sed -n '2p' | awk '{print $4}') + local ln=$(echo "$result" | sed -n '3p' | awk '{print $4}') + local lq=$(echo "$result" | sed -n '4p' | awk '{print $4}') + echo "House accounts:" + echo " sats (1): $sats" + echo " btc credit (2): $btc" + echo " ln credit (3): $ln" + echo " lq credit (4): $lq" +} + +# Raw TB account lookup by numeric ID(s), comma-separated +tb-raw() { + if [ -z "$1" ]; then echo "Usage: tb-raw <id>[,<id>...]"; return 1; fi + docker exec app bun -e " + const { createClient } = require('tigerbeetle-node'); + const { lookup } = require('node:dns/promises'); + (async () => { + const { address: ip } = await lookup('tb'); + const c = createClient({ cluster_id: 0n, replica_addresses: [\`\${ip}:3000\`] }); + const ids = '${1}'.split(',').map(BigInt); + const accs = await c.lookupAccounts(ids); + for (const a of accs) { + console.log(JSON.stringify({ + id: a.id.toString(), + ledger: a.ledger, + credits_posted: a.credits_posted.toString(), + debits_posted: a.debits_posted.toString(), + credits_pending: a.credits_pending.toString(), + debits_pending: a.debits_pending.toString(), + balance: (a.credits_posted - a.debits_posted).toString(), + }, null, 2)); + } + c.destroy(); + })(); + " 2>/dev/null +} + +echo "TB utils loaded: tb-balance, tb-pending, tb-credits, tb-user, tb-house, tb-raw" |
