154 lines
8.4 KiB
TypeScript
154 lines
8.4 KiB
TypeScript
import { coalitionGroups, type CoalitionSeat, type CoalitionState } from './coalition-alliance';
|
|
|
|
/** Already-escrowed spice, explicitly pledged by one donor to one ally.
|
|
* Wallet debits, pledge authorization, commitments and refunds remain the
|
|
* engine's responsibility; this ledger does not create or hold spice itself. */
|
|
export type CoalitionPledge = Readonly<{
|
|
donor: string;
|
|
recipient: string;
|
|
amount: number;
|
|
/** The particular alliance edge under which this escrow was authorized. */
|
|
epoch: string;
|
|
}>;
|
|
export type CoalitionFundingAuthorization = Readonly<{
|
|
/** Sorted pair of currently allied seats. */
|
|
members: readonly [string, string];
|
|
epoch: string;
|
|
}>;
|
|
export type CoalitionContribution = Readonly<{ donor: string; amount: number }>;
|
|
export type CoalitionFundingInput = Readonly<{
|
|
seats: readonly CoalitionSeat[];
|
|
coalitionState?: CoalitionState;
|
|
purpose: 'bid' | 'shipment';
|
|
payer: string;
|
|
cost: number;
|
|
/** Spendable payer spice after other obligations have been reserved. */
|
|
ownAvailable: number;
|
|
/** Authoritative current edge authorizations, never supplied by a client. */
|
|
authorizations: readonly CoalitionFundingAuthorization[];
|
|
pledges: readonly CoalitionPledge[];
|
|
contributions: readonly CoalitionContribution[];
|
|
}>;
|
|
export type CoalitionFundingQuote = {
|
|
purpose: 'bid' | 'shipment';
|
|
payer: string;
|
|
cost: number;
|
|
own: number;
|
|
contributions: { donor: string; amount: number }[];
|
|
};
|
|
|
|
export class CoalitionFundingError extends Error {
|
|
constructor(message: string) { super(message); this.name = 'CoalitionFundingError'; }
|
|
}
|
|
function requireFunding(condition: unknown, message: string): asserts condition {
|
|
if (!condition) throw new CoalitionFundingError(message);
|
|
}
|
|
const record = (value: unknown): value is Record<string, unknown> =>
|
|
!!value && typeof value === 'object' && !Array.isArray(value);
|
|
const amount = (value: unknown): value is number =>
|
|
Number.isSafeInteger(value) && Number(value) >= 0;
|
|
const pairKey = (donor: string, recipient: string) => JSON.stringify([donor, recipient]);
|
|
const edgeKey = (a: string, b: string) => JSON.stringify([a, b].sort());
|
|
const epoch = (value: unknown): value is string =>
|
|
typeof value === 'string' && value.length > 0 && value.length <= 256 && value.trim() === value;
|
|
|
|
/** The engine must mint a globally fresh, never-reused epoch on edge formation,
|
|
* preserve it while that edge remains allied, and revoke it atomically on a
|
|
* break. A re-formed edge must receive a different epoch even in the same turn.
|
|
* Legacy pairs need an issued epoch before funding may use this API. */
|
|
function fundingEdges(input: Pick<CoalitionFundingInput, 'seats' | 'coalitionState' | 'authorizations'>) {
|
|
const groups = coalitionGroups(input.seats, input.coalitionState);
|
|
requireFunding(Array.isArray(input.authorizations), 'Supply current authoritative alliance funding epochs.');
|
|
const expected = new Set<string>();
|
|
for (const group of groups) for (const edge of group.edges)
|
|
expected.add(edgeKey(edge.members[0], edge.members[1]));
|
|
const current = new Map<string, string>(), usedEpochs = new Set<string>();
|
|
for (const authorization of input.authorizations) {
|
|
requireFunding(record(authorization) && Array.isArray(authorization.members) &&
|
|
authorization.members.length === 2 && typeof authorization.members[0] === 'string' &&
|
|
typeof authorization.members[1] === 'string' && authorization.members[0] < authorization.members[1] &&
|
|
epoch(authorization.epoch), 'Each funding epoch needs an ordered pair of allies.');
|
|
const key = edgeKey(authorization.members[0], authorization.members[1]);
|
|
requireFunding(expected.has(key) && !current.has(key) && !usedEpochs.has(authorization.epoch),
|
|
'Funding epochs must uniquely match current alliance edges exactly.');
|
|
current.set(key, authorization.epoch);
|
|
usedEpochs.add(authorization.epoch);
|
|
}
|
|
requireFunding(current.size === expected.size, 'Every current alliance edge needs an authoritative funding epoch.');
|
|
return current;
|
|
}
|
|
|
|
/** Separate stale escrow for refund after a break or epoch replacement.
|
|
* The engine must resolve or cancel any affected committed payments and
|
|
* atomically refund every returned stale record before using the retained
|
|
* ledger. No wallet or pledge is changed by this pure function. */
|
|
export function reconcileCoalitionPledges(input: Pick<CoalitionFundingInput,
|
|
'seats' | 'coalitionState' | 'authorizations' | 'pledges'>): {
|
|
pledges: CoalitionPledge[]; refunds: CoalitionPledge[];
|
|
} {
|
|
requireFunding(record(input) && Array.isArray(input.pledges), 'Supply the complete pledged escrow ledger.');
|
|
const current = fundingEdges(input);
|
|
const seated = new Set(input.seats.map(seat => seat.id));
|
|
const seen = new Set<string>(), pledges: CoalitionPledge[] = [], refunds: CoalitionPledge[] = [];
|
|
for (const pledge of input.pledges) {
|
|
requireFunding(record(pledge) && typeof pledge.donor === 'string' && typeof pledge.recipient === 'string' &&
|
|
pledge.donor !== pledge.recipient && seated.has(pledge.donor) && seated.has(pledge.recipient) &&
|
|
amount(pledge.amount) && epoch(pledge.epoch), 'Every pledge needs seated parties, safe escrow and an epoch.');
|
|
const key = pairKey(pledge.donor, pledge.recipient);
|
|
requireFunding(!seen.has(key), 'Each donor may have only one pledge for a recipient.');
|
|
seen.add(key);
|
|
(current.get(edgeKey(pledge.donor, pledge.recipient)) === pledge.epoch ? pledges : refunds).push({
|
|
donor: pledge.donor, recipient: pledge.recipient, amount: pledge.amount, epoch: pledge.epoch,
|
|
});
|
|
}
|
|
return { pledges, refunds };
|
|
}
|
|
|
|
/** Validate an exact declared split against current coalition membership and
|
|
* each recipient-specific escrow. The group-funding and allocation procedure
|
|
* is an online interpretation; the printed rule permits allies to help pay.
|
|
* This is not an authorization, reservation, or a promise of future funds. */
|
|
export function quoteCoalitionFunding(input: CoalitionFundingInput): CoalitionFundingQuote {
|
|
requireFunding(record(input) && (input.purpose === 'bid' || input.purpose === 'shipment') &&
|
|
amount(input.cost) && amount(input.ownAvailable) && Array.isArray(input.pledges) &&
|
|
Array.isArray(input.contributions), 'Choose a valid bid or shipment and safe spice amounts.');
|
|
requireFunding(typeof input.payer === 'string' && input.seats.some(seat => seat.id === input.payer),
|
|
'Choose a seated payer.');
|
|
const reconciled = reconcileCoalitionPledges(input);
|
|
requireFunding(reconciled.refunds.length === 0, 'Reconcile and refund stale pledge escrow before payment.');
|
|
const pledges = new Map<string, CoalitionPledge>();
|
|
for (const pledge of reconciled.pledges) pledges.set(pairKey(pledge.donor, pledge.recipient), pledge);
|
|
const donors = new Set<string>();
|
|
const contributions: { donor: string; amount: number }[] = [];
|
|
let own = input.cost;
|
|
for (const share of input.contributions) {
|
|
requireFunding(record(share) && typeof share.donor === 'string' && amount(share.amount) && share.amount > 0 &&
|
|
!donors.has(share.donor), 'Declare each donating ally once with a positive safe amount.');
|
|
const pledge = pledges.get(pairKey(share.donor, input.payer));
|
|
requireFunding(pledge && share.amount <= pledge.amount,
|
|
'Each contribution needs enough current escrow pledged to this payer.');
|
|
requireFunding(share.amount <= own, 'Contributions cannot exceed the cost.');
|
|
own -= share.amount;
|
|
donors.add(share.donor);
|
|
contributions.push({ donor: share.donor, amount: share.amount });
|
|
}
|
|
requireFunding(own <= input.ownAvailable, 'The payer cannot cover the remaining cost.');
|
|
return { purpose: input.purpose, payer: input.payer, cost: input.cost, own, contributions };
|
|
}
|
|
|
|
/** Revalidate the complete current input at the moment of payment and return
|
|
* a new pledge ledger. The engine must debit quote.own and install this ledger
|
|
* in the same authoritative action; recipient routing and replay protection
|
|
* remain native transaction responsibilities. Never apply a stale quote alone. */
|
|
export function settleCoalitionFunding(input: CoalitionFundingInput): {
|
|
quote: CoalitionFundingQuote;
|
|
pledges: CoalitionPledge[];
|
|
} {
|
|
const quote = quoteCoalitionFunding(input);
|
|
const debits = new Map(quote.contributions.map(share => [share.donor, share.amount]));
|
|
const pledges = input.pledges.map(pledge => ({ ...pledge,
|
|
amount: pledge.amount - (pledge.recipient === quote.payer ? debits.get(pledge.donor) ?? 0 : 0),
|
|
}));
|
|
return { quote, pledges };
|
|
}
|