Files
apxtri/models/Ledger.js
T
2026-09-26 21:42:06 +00:00

261 lines
10 KiB
JavaScript

const fs = require("fs-extra");
const crypto = require("crypto");
const Odmdb = require("./Odmdb.js");
const APX3 = require("./APX3.js");
const TOWN = process.env.TOWN || "dev";
const log = process.env.ACTIVELOG?.split(',').includes('Ledger');
/**
* Local off-chain ledgers ("ardoises").
*
* A ledger is bound to a context (a tribe or the town). The validator (tribe
* druid / town major) records transfers between members instantly with a light
* fee (feePercent, credited to the validator). Positions are netted and settled
* on-chain periodically via `settle()`: net debtors pay the validator, the
* validator pays net creditors — the chain only ever sees the net amounts.
*
* Ledgers are stored in the town namespace so they are covered by the Merkle
* anchoring; the committed record is tamper-evident without replicating the
* whole ledger.
*/
class Ledger {
static _db() {
return new Odmdb("ledgers", TOWN);
}
static _role(alias, profils) {
return { xalias: alias, xprofils: profils || ["owner"] };
}
static _adminRole() {
return { xalias: "system", xprofils: ["admin"] };
}
static async _get(context) {
const r = await this._db().read(context, this._adminRole());
if (r.status === 200) return r.data;
return { ledgerId: context, type: "tribe", validatedBy: "", feePercent: 0, entries: [] };
}
static _isValidator(ledger, role) {
const profs = role.xprofils || [];
if (profs.includes("admin") || profs.includes("mayor") || profs.includes("druids")) return true;
return ledger.validatedBy === role.xalias;
}
/**
* Creates a ledger for a context.
* @param {string} context - Tribe id or town id
* @param {Object} data - { type, validatedBy, feePercent }
* @param {Object} role - Session role
*/
static async create(context, data, role) {
try {
if (!context) return { status: 400, ref: "Ledger", msg: "invalid_ledger_context", data: {} };
const db = this._db();
const existing = await db.read(context, this._role(role.xalias, role.xprofils));
if (existing.status === 200) {
return { status: 409, ref: "Ledger", msg: "ledger_already_exists", data: { context } };
}
const item = {
ledgerId: context,
type: (data && data.type) || "tribe",
validatedBy: (data && data.validatedBy) || role.xalias,
feePercent: data && data.feePercent != null ? parseFloat(data.feePercent) : 0,
entries: [],
updatedAt: new Date().toISOString()
};
const r = await db.create(item, this._role(role.xalias, role.xprofils));
if (r.status !== 200) return r;
return { status: 200, ref: "Ledger", msg: "ledger_created", data: { context } };
} catch (e) {
return { status: 500, ref: "Ledger", msg: "ledger_error", error: e.message };
}
}
/**
* Records a validated local transfer.
* The sender pays `amount` total; feePercent goes to the validator, the rest
* to the recipient. A single entry stores the gross amount and the fee; net
* positions are derived (sender -gross, recipient +gross-fee, validator +fee).
* @param {string} context - Ledger context
* @param {Object} data - { from, to, amount, memo }
* @param {Object} role - Session role (must be a validator)
*/
static async record(context, data, role) {
try {
const from = data && data.from;
const to = data && data.to;
const amount = parseFloat(data && data.amount);
if (!context || !from || !to || !(amount > 0)) {
return { status: 400, ref: "Ledger", msg: "invalid_ledger_entry", data: { context } };
}
const ledger = await this._get(context);
if (!this._isValidator(ledger, role)) {
return { status: 403, ref: "Ledger", msg: "ledger_not_validator", data: { context } };
}
const fee = Math.round(amount * (ledger.feePercent || 0) / 100 * 100) / 100;
const now = new Date().toISOString();
const entry = {
id: crypto.randomUUID(),
ts: now,
from,
to,
amount, // gross
fee,
memo: (data.memo || "").slice(0, 200),
validatedBy: role.xalias,
settled: false
};
const entries = ledger.entries.slice();
entries.push(entry);
if (entries.length > 2000) entries.splice(0, entries.length - 2000);
const merged = Object.assign({}, ledger, { ledgerId: context, entries, updatedAt: now });
const r = await this._db().update(merged, this._role(role.xalias, role.xprofils));
if (r.status !== 200) return r;
if (log) console.log(`[Ledger] ${context}: ${from} -> ${to} ${amount} APX3 (fee ${fee}) by ${role.xalias}`);
return { status: 200, ref: "Ledger", msg: "ledger_entry_recorded", data: { context, id: entry.id, amount, fee } };
} catch (e) {
return { status: 500, ref: "Ledger", msg: "ledger_error", error: e.message };
}
}
/**
* Returns a ledger with its entries.
*/
static async get(context, role) {
try {
const r = await this._db().read(context, this._role(role.xalias, role.xprofils));
if (r.status !== 200) {
return { status: 404, ref: "Ledger", msg: "ledger_not_found", data: { context } };
}
return { status: 200, ref: "Ledger", msg: "ledger_fetched", data: r.data };
} catch (e) {
return { status: 500, ref: "Ledger", msg: "ledger_error", error: e.message };
}
}
/**
* Computes the net position of every alias in the ledger.
* sender: -gross ; recipient: +(gross-fee) ; validator: +fee
* (when recipient == validator, the validator receives the full gross).
* @param {string} context - Ledger context
* @returns {Promise<Object>} { net: {alias: amount}, unsettledOnly: bool }
*/
static async net(context, unsettledOnly = true) {
try {
const ledger = await this._get(context);
const net = {};
const bump = (alias, delta) => {
if (!alias) return;
net[alias] = Math.round(((net[alias] || 0) + delta) * 100) / 100;
};
for (const e of ledger.entries || []) {
if (unsettledOnly && e.settled) continue;
const gross = e.amount || 0;
const fee = e.fee || 0;
bump(e.from, -gross);
if (e.to === e.validatedBy) {
bump(e.to, gross);
} else {
bump(e.to, gross - fee);
bump(e.validatedBy, fee);
}
}
return { status: 200, ref: "Ledger", msg: "ledger_net", data: { context, net, validatedBy: ledger.validatedBy, unsettledOnly } };
} catch (e) {
return { status: 500, ref: "Ledger", msg: "ledger_error", error: e.message };
}
}
static async balance(context, alias) {
try {
const r = await this.net(context, true);
const net = r.data.net || {};
return { status: 200, ref: "Ledger", msg: "ledger_balance", data: { context, alias, balance: net[alias] || 0 } };
} catch (e) {
return { status: 500, ref: "Ledger", msg: "ledger_error", error: e.message };
}
}
/**
* Netted on-chain settlement of the local ledger (hub model, atomic).
* All net debtors must sign first; if any signature is missing the
* settlement is aborted (202) WITHOUT executing any transfer (prevents
* double payment). Otherwise each net debtor pays the validator |net| and
* the validator pays each net creditor. On full success the entries are
* marked settled and the ledger is reset to a zero position.
* @param {string} context - Ledger context
* @param {Object} signatures - { alias: xhash } authorizing each debtor's payment
* @param {Object} role - Session role (must be the validator)
*/
static async settle(context, signatures, role) {
try {
const ledger = await this._get(context);
if (!this._isValidator(ledger, role)) {
return { status: 403, ref: "Ledger", msg: "ledger_not_validator", data: { context } };
}
const r = await this.net(context, true);
const net = r.data.net || {};
const validator = ledger.validatedBy || role.xalias;
const txids = [];
const pending = [];
// Atomic: every net debtor must have signed before any transfer is made.
const debtors = Object.keys(net).filter((a) => net[a] < 0);
const missing = debtors.filter((a) => !(signatures || {})[a]);
if (missing.length > 0) {
missing.forEach((a) => pending.push({ alias: a, amount: Math.abs(net[a]) }));
return { status: 202, ref: "Ledger", msg: "ledger_settlement_partial", data: { context, txids: [], pending, failures: [] } };
}
const failures = [];
for (const alias of debtors) {
try {
const tx = await APX3.createTransaction({ from: alias, to: validator, amount: Math.abs(net[alias]), signature: (signatures || {})[alias], memo: "ledger_settle " + context });
await APX3.applyTransaction(tx);
txids.push(tx.txid);
} catch (e) {
failures.push({ alias, amount: Math.abs(net[alias]), error: e.message });
}
}
const creditors = Object.keys(net).filter((a) => net[a] > 0);
for (const alias of creditors) {
try {
const tx = await APX3.createTransaction({ from: validator, to: alias, amount: net[alias], signature: role.xhash, memo: "ledger_settle " + context });
await APX3.applyTransaction(tx);
txids.push(tx.txid);
} catch (e) {
failures.push({ alias, amount: net[alias], error: e.message });
}
}
const allSettled = failures.length === 0;
if (allSettled) {
const entries = (ledger.entries || []).map((e) => Object.assign({}, e, { settled: true }));
const merged = Object.assign({}, ledger, {
ledgerId: context,
entries,
updatedAt: new Date().toISOString(),
lastSettledAt: new Date().toISOString(),
lastSettlement: { at: new Date().toISOString(), txids }
});
await this._db().update(merged, this._role(role.xalias, role.xprofils));
}
return {
status: allSettled ? 200 : 202,
ref: "Ledger",
msg: allSettled ? "ledger_settled" : "ledger_settlement_partial",
data: { context, txids, pending, failures }
};
} catch (e) {
return { status: 500, ref: "Ledger", msg: "ledger_error", error: e.message };
}
}
}
module.exports = Ledger;