/**
* Web Ledger spec-compliant balance tracking
*
* Implements the Web Ledgers specification (https://webledgers.org/)
* for mapping URIs to numerical balances. Default unit: satoshi.
*
* JSON-LD context: https://w3id.org/webledgers
*
* @module webledger
*/
import * as storage from './storage/filesystem.js';
const DEFAULT_PATH = '/.well-known/webledgers/webledgers.json';
const CONTEXT = 'https://w3id.org/webledgers';
/**
* Create an empty spec-compliant ledger
* @param {object} options
* @param {string} options.name - Ledger name
* @param {string} options.description - Ledger description
* @param {string} options.id - Ledger URI identifier
* @param {string} options.defaultCurrency - Default currency (default 'satoshi')
* @returns {object} Empty WebLedger object
*/
export function createLedger(options = {}) {
const now = Math.floor(Date.now() / 1000);
return {
'@context': CONTEXT,
type: 'WebLedger',
...(options.id && { id: options.id }),
name: options.name ?? 'Pod Credits',
description: options.description ?? 'Paid API balance ledger',
defaultCurrency: options.defaultCurrency ?? 'satoshi',
created: now,
updated: now,
entries: []
};
}
/**
* Read a webledger from storage
* @param {string} ledgerPath - URL path to ledger file
* @returns {Promise} WebLedger object
*/
export async function readLedger(ledgerPath = DEFAULT_PATH) {
const buf = await storage.read(ledgerPath);
if (!buf) {
return createLedger();
}
try {
const ledger = JSON.parse(buf.toString('utf8'));
// Migrate legacy format: add missing spec fields
if (!ledger['@context']) ledger['@context'] = CONTEXT;
if (!ledger.type) ledger.type = 'WebLedger';
if (!ledger.defaultCurrency) ledger.defaultCurrency = 'satoshi';
if (!ledger.created) ledger.created = Math.floor(Date.now() / 1000);
if (!ledger.entries) ledger.entries = [];
return ledger;
} catch {
return createLedger();
}
}
/**
* Write a webledger to storage (updates the `updated` timestamp)
* @param {object} ledger - WebLedger object
* @param {string} ledgerPath - URL path to ledger file
* @returns {Promise}
*/
export async function writeLedger(ledger, ledgerPath = DEFAULT_PATH) {
ledger.updated = Math.floor(Date.now() / 1000);
return storage.write(ledgerPath, JSON.stringify(ledger, null, 2));
}
/**
* Get balance for a URI
* @param {object} ledger - WebLedger object
* @param {string} uri - Agent URI (e.g. did:nostr:...)
* @param {string} [currency] - Currency code (e.g. 'tbtc3', 'tbtc4'). If omitted, reads default/simple amount.
* @returns {number} Balance as integer
*/
export function getBalance(ledger, uri, currency) {
const entry = ledger.entries.find(e => e.url === uri);
if (!entry) return 0;
// Handle array amount format
if (Array.isArray(entry.amount)) {
const target = currency
? entry.amount.find(a => a.currency === currency)
: entry.amount.find(a => a.currency === 'satoshi' || a.currency === 'sat');
return target ? parseInt(target.value, 10) || 0 : 0;
}
// Simple string format only if no specific currency requested, or currency matches default
if (currency) return 0;
return parseInt(entry.amount, 10) || 0;
}
/**
* Set balance for a URI
* @param {object} ledger - WebLedger object
* @param {string} uri - Agent URI
* @param {number} amount - New balance
* @param {string} [currency] - Currency code. If provided, uses array amount format.
*/
export function setBalance(ledger, uri, amount, currency) {
let entry = ledger.entries.find(e => e.url === uri);
if (!currency) {
// Simple string format (backward compatible)
if (entry) {
entry.amount = String(amount);
} else {
ledger.entries.push({ type: 'Entry', url: uri, amount: String(amount) });
}
return;
}
// Multi-currency: use array format
if (!entry) {
entry = { type: 'Entry', url: uri, amount: [] };
ledger.entries.push(entry);
}
// Migrate simple string to array if needed
if (!Array.isArray(entry.amount)) {
const oldVal = parseInt(entry.amount, 10) || 0;
entry.amount = oldVal > 0 ? [{ currency: 'satoshi', value: String(oldVal) }] : [];
}
const existing = entry.amount.find(a => a.currency === currency);
if (existing) {
existing.value = String(amount);
} else {
entry.amount.push({ currency, value: String(amount) });
}
}
/**
* Credit (add to) a balance
* @param {object} ledger - WebLedger object
* @param {string} uri - Agent URI
* @param {number} amount - Amount to add
* @param {string} [currency] - Currency code
* @returns {number} New balance
*/
export function credit(ledger, uri, amount, currency) {
const current = getBalance(ledger, uri, currency);
const newBalance = current + amount;
setBalance(ledger, uri, newBalance, currency);
return newBalance;
}
/**
* Debit (subtract from) a balance
* @param {object} ledger - WebLedger object
* @param {string} uri - Agent URI
* @param {number} amount - Amount to subtract
* @param {string} [currency] - Currency code
* @returns {{success: boolean, balance: number}} Result
*/
export function debit(ledger, uri, amount, currency) {
const current = getBalance(ledger, uri, currency);
if (current < amount) {
return { success: false, balance: current };
}
const newBalance = current - amount;
setBalance(ledger, uri, newBalance, currency);
return { success: true, balance: newBalance };
}
/**
* List all entries with non-zero balances
* @param {object} ledger - WebLedger object
* @returns {Array}
*/
export function listBalances(ledger) {
return ledger.entries
.map(e => ({ url: e.url, amount: getBalance(ledger, e.url) }))
.filter(e => e.amount > 0);
}
/**
* Remove entries with zero balance
* @param {object} ledger - WebLedger object
*/
export function compact(ledger) {
ledger.entries = ledger.entries.filter(e => {
const bal = getBalance(ledger, e.url);
return bal > 0;
});
}
// Re-export the default path for consumers
export { DEFAULT_PATH as LEDGER_PATH };