261 lines
10 KiB
JavaScript
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;
|