aboutsummaryrefslogtreecommitdiffstats
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
parentc0481268da25eed1f25281508b5c4777d26c2b03 (diff)
downloadcoinos-server-1ebc40afc14a75d7fe157b2c6c20ffea9dedb5eb.tar.gz
coinos-server-1ebc40afc14a75d7fe157b2c6c20ffea9dedb5eb.zip
tigerbeetle
-rwxr-xr-xbun.lockbbin234863 -> 235305 bytes
-rw-r--r--index.ts5
-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
-rw-r--r--package.json1
-rw-r--r--routes/ecash.ts12
-rw-r--r--routes/payments.ts22
-rw-r--r--routes/users.ts39
-rwxr-xr-xscripts/tb.sh165
14 files changed, 888 insertions, 155 deletions
diff --git a/bun.lockb b/bun.lockb
index d596ea75..a7fc62c1 100755
--- a/bun.lockb
+++ b/bun.lockb
Binary files differ
diff --git a/index.ts b/index.ts
index b1e4ef4d..68aec5ea 100644
--- a/index.ts
+++ b/index.ts
@@ -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);
}
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;
+}
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"