aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-04 23:13:33 -0800
committerAdam Soltys <[email protected]>2026-02-06 09:00:09 -0800
commit1ebc40afc14a75d7fe157b2c6c20ffea9dedb5eb (patch)
tree960318969072afa63536e9429c27fa40ca2919d3 /lib
parentc0481268da25eed1f25281508b5c4777d26c2b03 (diff)
downloadcoinos-server-1ebc40afc14a75d7fe157b2c6c20ffea9dedb5eb.tar.gz
coinos-server-1ebc40afc14a75d7fe157b2c6c20ffea9dedb5eb.zip
tigerbeetle
Diffstat (limited to 'lib')
-rw-r--r--lib/db.ts67
-rw-r--r--lib/lightning.ts4
-rw-r--r--lib/migrate.ts58
-rw-r--r--lib/nwc.ts3
-rw-r--r--lib/payments.ts90
-rw-r--r--lib/register.ts5
-rw-r--r--lib/tb.ts572
7 files changed, 687 insertions, 112 deletions
diff --git a/lib/db.ts b/lib/db.ts
index 91770824..5ebe38b5 100644
--- a/lib/db.ts
+++ b/lib/db.ts
@@ -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;
diff --git a/lib/nwc.ts b/lib/nwc.ts
index c888ee55..11b1d544 100644
--- a/lib/nwc.ts
+++ b/lib/nwc.ts
@@ -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;
+}