@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
304 lines (303 loc) • 12.8 kB
JavaScript
/**
* Receipts, and the reciprocal netting built on them.
*
* **The problem receipts solve.** Until now the lender's word was the only
* record of what a borrowed request cost. The lender settles from usage the
* borrower never sees a ledger of, and a complete-mode borrower self-reports
* spend the lender cannot see at all. Each side simply believed the other.
*
* A receipt is the lender's signed statement that one request was settled, for
* how much, and against what usage. It travels with the usage block, so the
* borrower does not have to accept the coin figure — it recomputes the charge
* from the response it actually received and compares. `sequence` is contiguous
* per grant, so a receipt withheld to hide a charge leaves a hole.
*
* What it cannot do: an HMAC proves authorship only to the holder of the key, so
* a receipt settles a dispute *between the two parties to it* and is worth
* nothing to a third. That is the deliberate trade — see `shareSigning.ts`.
*
* **Netting.** Two nodes that lend to each other otherwise accumulate two
* one-way debts that never meet. Netting forgives the overlap: if a borrower has
* consumed 300 coins of mine and I have consumed 500 of theirs, 300 cancels on
* both sides. It is expressed in *cumulative* totals rather than deltas, so a
* replayed or duplicated round nets zero rather than paying twice.
*
* @module proxy/shareReceipts
*/
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { AsyncMutex } from "../utils/asyncMutex.js";
import { logger } from "../utils/logger.js";
import { signSharePayload, verifySharePayload } from "./shareSigning.js";
import { usageToCoins } from "./shareLedger.js";
import { creditShareGrantCoins, getShareGrant } from "./shareGrants.js";
const RECEIPTS_FILE = "proxy-share-receipts.json";
/**
* Receipts kept per grant.
*
* Enough that a borrower checking in daily never misses one, bounded so a busy
* grant cannot grow the file without limit. A borrower that falls further behind
* than this sees a gap, which is the honest answer — the alternative is silently
* dropping the evidence and reporting a clean run.
*/
export const RECEIPT_HISTORY_LIMIT = 500;
let customFilePath = null;
let receipts = {};
let netted = {};
/**
* Lifetime coins and highest sequence per grant.
*
* Both exist because `receipts` is trimmed to {@link RECEIPT_HISTORY_LIMIT}.
* Deriving either from the retained history is correct only until the first
* trim, after which a busy grant's cumulative position silently resets — which
* on the netting path would forgive coins that were already forgiven, and on
* the issuing path would reuse sequence numbers the borrower has already seen.
*/
let consumedTotal = {};
let highestSequence = {};
let loaded = false;
const mutationMutex = new AsyncMutex();
export function initShareReceipts(filePath) {
customFilePath = filePath;
receipts = {};
netted = {};
consumedTotal = {};
highestSequence = {};
loaded = false;
}
function getFilePath() {
return customFilePath ?? join(homedir(), ".neurolink", RECEIPTS_FILE);
}
async function ensureLoaded(options = {}) {
if (loaded && !options.force) {
return;
}
try {
const parsed = JSON.parse(await readFile(getFilePath(), "utf8"));
receipts = parsed?.receipts ?? {};
netted = parsed?.netted ?? {};
// A file written before these were tracked carries neither. Seeding them
// from the retained history is the best available answer and is exact for
// any grant that has not yet been trimmed.
consumedTotal =
parsed?.consumedTotal ??
seedFromHistory((sum, receipt) => sum + receipt.coins);
highestSequence =
parsed?.highestSequence ??
seedFromHistory((top, receipt) => Math.max(top, receipt.sequence));
}
catch {
receipts = {};
netted = {};
consumedTotal = {};
highestSequence = {};
}
loaded = true;
}
/** Rebuild a per-grant total from whatever history survived trimming. */
function seedFromHistory(fold) {
return Object.fromEntries(Object.entries(receipts).map(([grantId, history]) => [
grantId,
history.reduce(fold, 0),
]));
}
async function persist() {
const { writeJsonSnapshotAtomically } = await import("./snapshotPersistence.js");
const file = {
schemaVersion: 1,
receipts,
netted,
consumedTotal,
highestSequence,
};
await writeJsonSnapshotAtomically(getFilePath(), file);
}
/** Everything the signature covers — the receipt minus the signature itself. */
function receiptPayload(receipt) {
return receipt;
}
/**
* Record and sign a settlement.
*
* Never throws: a receipt that could not be written must not turn a response the
* borrower already has into an error. A grant with no receipt secret — one
* issued before receipts existed — is skipped rather than signed with nothing.
*/
export async function issueShareReceipt(args) {
try {
const grant = await getShareGrant(args.grantId);
const secret = grant?.receiptSecret;
if (!secret) {
return undefined;
}
return await mutationMutex.runExclusive(async () => {
await ensureLoaded();
const history = receipts[args.grantId] ?? [];
// From the recorded high-water mark, not the retained tail: the tail is
// trimmed, and restarting the sequence would read as a replay.
const previous = highestSequence[args.grantId] ?? 0;
const unsigned = {
schemaVersion: 1,
grantId: args.grantId,
sequence: previous + 1,
settledAt: args.settledAt ?? Date.now(),
...(args.model ? { model: args.model } : {}),
usage: args.usage,
coins: args.coins,
balanceAfter: args.balanceAfter,
};
const receipt = {
...unsigned,
signature: signSharePayload(receiptPayload(unsigned), secret),
};
history.push(receipt);
// Oldest first, so trimming the front is trimming the oldest.
receipts[args.grantId] = history.slice(-RECEIPT_HISTORY_LIMIT);
highestSequence[args.grantId] = receipt.sequence;
consumedTotal[args.grantId] =
(consumedTotal[args.grantId] ?? 0) + args.coins;
await persist();
return receipt;
});
}
catch (error) {
logger.always(`[proxy] could not record a receipt for grant=${args.grantId}: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
}
}
/** Receipts a borrower has not collected yet. */
export async function listShareReceipts(grantId, since = 0) {
await ensureLoaded({ force: true });
return (receipts[grantId] ?? []).filter((receipt) => receipt.sequence > since);
}
/**
* Check a collected run of receipts against the secret and against itself.
*
* Three independent questions, because they have different answers:
* a signature that does not verify means the receipt did not come from this
* lender; a coin figure that disagrees with its own usage block means the
* lender's arithmetic (or its price table) differs from ours; a missing
* sequence means a charge was never shown to us at all.
*/
export function auditShareReceipts(grantId, collected, secret) {
let coins = 0;
let unverified = 0;
let miscounted = 0;
const seen = new Set();
for (const receipt of collected) {
coins += receipt.coins;
seen.add(receipt.sequence);
const { signature, ...unsigned } = receipt;
if (!secret || !verifySharePayload(unsigned, signature, secret)) {
unverified += 1;
}
const expected = usageToCoins(receipt.usage, receipt.model);
// A tenth of a coin is a hundred normalized tokens — below any rounding
// this pipeline introduces, and far below a charge worth arguing about.
if (Math.abs(expected - receipt.coins) > 0.1) {
miscounted += 1;
}
}
const latestSequence = collected.reduce((highest, receipt) => Math.max(highest, receipt.sequence), 0);
// Anchored to the lowest sequence actually collected, not to 1. A borrower
// that collects incrementally asks for everything after the last sequence it
// holds, and every earlier receipt — already collected, or trimmed from the
// lender's bounded history — would otherwise be reported as a withheld one.
const earliestSequence = collected.reduce((lowest, receipt) => Math.min(lowest, receipt.sequence), latestSequence);
const gaps = [];
for (let sequence = earliestSequence; sequence <= latestSequence; sequence += 1) {
if (!seen.has(sequence)) {
gaps.push(sequence);
}
}
return {
grantId,
receipts: collected.length,
coins,
unverified,
miscounted,
gaps,
latestSequence,
};
}
/** Cumulative coins a grant has consumed, as its receipts record it. */
export async function totalReceiptedCoins(grantId) {
await ensureLoaded({ force: true });
return consumedTotal[grantId] ?? 0;
}
/** Cumulative coins already forgiven on a grant by netting. */
export async function nettedCoinsFor(grantId) {
await ensureLoaded({ force: true });
return netted[grantId] ?? 0;
}
/**
* Settle one round of reciprocal netting, from the lender's side.
*
* Netting forgives the same amount on both sides, so the cumulative total
* forgiven is one number the two nodes hold a copy of each. The round is
* therefore the overlap that has not been forgiven yet:
*
* ```
* forgivable = min(coins they consumed of mine, coins I consumed of theirs)
* alreadyForgiven = max(my record, their record)
* round = max(0, forgivable - alreadyForgiven)
* ```
*
* **Why the totals and not a delta.** A delta is a number the caller chooses,
* and a replayed round would pay it out again. Deriving the round from
* cumulative positions makes a replay free by construction: the second call
* subtracts the first one's forgiveness and lands on zero.
*
* **Why `max` over the two records.** They should agree. When they do not — a
* round applied on one side and lost on the other — taking the larger forgives
* less, which is the direction that cannot hand out coins twice.
*/
export async function applyReciprocalNetting(args) {
return mutationMutex.runExclusive(async () => {
await ensureLoaded({ force: true });
// The lifetime figure, not the retained history: netting forgives against
// cumulative positions, and a trimmed sum would forgive the same coins
// again on the next round.
const consumedByPeer = consumedTotal[args.grantId] ?? 0;
const alreadyNetted = Math.max(netted[args.grantId] ?? 0, Math.max(0, args.peerAlreadyNetted));
const forgivable = Math.min(consumedByPeer, Math.max(0, args.consumedFromPeer));
const round = Math.max(0, forgivable - alreadyNetted);
if (round <= 0) {
return {
netted: 0,
totalNetted: alreadyNetted,
detail: consumedByPeer <= alreadyNetted
? "nothing of mine left to forgive"
: "nothing of theirs left to offset it against",
};
}
netted[args.grantId] = alreadyNetted + round;
await persist();
// Forgiveness is a credit: the borrower gets back what its own lending
// cancelled out. An unlimited grant has no balance to credit, and netting
// there is bookkeeping only.
await creditShareGrantCoins(args.grantId, round);
return {
netted: round,
totalNetted: netted[args.grantId],
detail: `forgave ${round.toFixed(1)} coins against reciprocal use`,
};
});
}
/** Drop a grant's receipts and netting position. Used when the grant goes. */
export async function clearShareReceipts(grantId) {
await mutationMutex.runExclusive(async () => {
await ensureLoaded({ force: true });
if (receipts[grantId] ||
netted[grantId] !== undefined ||
consumedTotal[grantId] !== undefined ||
highestSequence[grantId] !== undefined) {
delete receipts[grantId];
delete netted[grantId];
delete consumedTotal[grantId];
delete highestSequence[grantId];
await persist();
}
});
}