/** Read-only XRP topology observations at one validated ledger. Not a universal loss bound. */
import type { Client } from 'xrpl';
import type { ReinsPolicy } from '@reinskit/policy';
export interface InspectReport {
schemaVersion: 1;
complete: boolean;
policyMatches?: boolean;
snapshot: {
ledgerIndex: number;
ledgerHash: string;
closeTime: number;
network: string;
};
accounts: {
vault: string;
allowance: string;
agent: string;
};
permissions: string[];
exposure: {
spendableDrops: string | null;
refillDrops: string | null;
reachableDrops: string | null;
feeFloatDrops: string | null;
};
findings: {
code: string;
message: string;
}[];
limitations: string[];
}
type Json = Record<string, unknown>;
const FEES = '4BC50C9B0D8515D3EAAE1E74B29A95804346C491EE1A95BF25E4AAB854A6A651';
const SUPPORTED = new Set(['Payment', 'CheckCash', 'EscrowFinish']);
function object(v: unknown): Json { if (!v || typeof v !== 'object' || Array.isArray(v))
throw Error('Expected ledger object'); return v as Json; }
function uint(v: unknown, label: string): bigint {
if (typeof v === 'string' && /^\d+$/.test(v))
return BigInt(v);
if (typeof v === 'number' && Number.isSafeInteger(v) && v >= 0)
return BigInt(v);
throw Error(`Invalid or missing ${label}`);
}
function integer(v: unknown, label: string): number { const n = uint(v, label); if (n > BigInt(Number.MAX_SAFE_INTEGER))
throw Error(`Invalid ${label}`); return Number(n); }
const max0 = (n: bigint) => n > 0n ? n : 0n;
export async function inspectPolicy(client: Client, policy: ReinsPolicy): Promise<InspectReport> {
const report: InspectReport = {
schemaVersion: 1, complete: false,
snapshot: { ledgerIndex: 0, ledgerHash: '', closeTime: 0, network: policy.network },
accounts: { ...policy.accounts }, permissions: [],
exposure: { spendableDrops: null, refillDrops: null, reachableDrops: null, feeFloatDrops: null }, findings: [],
limitations: [
'Read-only XRP observations, not a universal worst-case loss bound. Agent funds are shown separately.',
'Network is declared by the policy, not independently established from the endpoint.',
'Co-signer policy limits are inactive. Toolkit observations do not add ledger enforcement.',
'Refills are contingent caps, not escrowed funds. Future owner funding and future ledger changes are excluded.',
'Refill caps use the full current vault balance conservatively because cashing Checks can release owner reserve; actual cashable funds may be lower.',
],
};
const finding = (code: string, message: string) => report.findings.push({ code, message });
try {
const first = object((await client.request({ command: 'ledger', ledger_index: 'validated' })).result);
const ledger = object(first.ledger);
if (first.validated !== true)
throw Error('Ledger snapshot is not explicitly validated');
const hash = first.ledger_hash ?? ledger.ledger_hash;
if (typeof hash !== 'string' || !/^[A-Fa-f0-9]{64}$/.test(hash))
throw Error('Missing validated ledger hash');
report.snapshot = { ledgerHash: hash, ledgerIndex: integer(first.ledger_index ?? ledger.ledger_index, 'ledger index'), closeTime: integer(ledger.close_time, 'ledger close time'), network: policy.network };
async function read(req: Json): Promise<Json> {
const response = await client.request({ ...req, ledger_hash: hash } as never) as unknown as {
result: unknown;
};
const result = object(response.result);
if (result.validated === false)
throw Error('Response is not validated');
if (result.ledger_hash !== undefined && result.ledger_hash !== hash)
throw Error('Response ledger hash differs from snapshot');
if (result.ledger_index !== undefined && integer(result.ledger_index, 'response ledger index') !== report.snapshot.ledgerIndex)
throw Error('Response ledger index differs from snapshot');
return result;
}
async function objs(account: string): Promise<Json[]> {
const all: Json[] = [];
let marker: unknown;
const seen = new Set<string>();
do {
const result = await read({ command: 'account_objects', account, limit: 400, ...marker === undefined ? {} : { marker } });
if (result.account !== undefined && result.account !== account)
throw Error('Object response identity differs from request');
if (!Array.isArray(result.account_objects))
throw Error('Missing account objects');
all.push(...result.account_objects.map(object));
marker = result.marker;
if (marker !== undefined) {
const key = JSON.stringify(marker);
if (seen.has(key))
throw Error('Repeated pagination marker');
seen.add(key);
}
} while (marker !== undefined);
return all;
}
const info: Record<string, Json> = {};
const objects: Record<string, Json[]> = {};
for (const role of ['vault', 'allowance', 'agent'] as const) {
const address = policy.accounts[role];
info[role] = object((await read({ command: 'account_info', account: address })).account_data);
if (info[role]!.Account !== undefined && info[role]!.Account !== address)
throw Error('Account response identity differs from request');
objects[role] = await objs(address);
}
const fees = object((await read({ command: 'ledger_entry', index: FEES })).node);
if (fees.LedgerEntryType !== 'FeeSettings')
throw Error('Missing FeeSettings entry');
const base = uint(fees.ReserveBaseDrops ?? fees.ReserveBase, 'reserve base');
const increment = uint(fees.ReserveIncrementDrops ?? fees.ReserveIncrement, 'reserve increment');
const balance = (role: string) => uint(info[role]!.Balance, `${role} balance`);
const spendable = (role: string) => max0(balance(role) - base - increment * uint(info[role]!.OwnerCount, `${role} owner count`));
report.exposure.feeFloatDrops = balance('agent').toString();
report.exposure.spendableDrops = spendable('allowance').toString();
const delegates = objects.allowance!.filter(o => o.LedgerEntryType === 'Delegate');
const delegate = delegates.find(o => o.Authorize === policy.accounts.agent);
if (delegate) {
if (!Array.isArray(delegate.Permissions))
throw Error('Missing delegated permissions');
report.permissions = delegate.Permissions.map(p => { const value = object(object(p).Permission).PermissionValue; if (typeof value !== 'string')
throw Error('Invalid delegated permission'); return value; }).sort();
}
const want = [...policy.agent.permissions].sort();
report.policyMatches = !!delegate && JSON.stringify(want) === JSON.stringify(report.permissions);
if (!report.policyMatches)
finding('POLICY_DRIFT', 'Live delegation differs from policy intent; no live delegation is also a mismatch.');
let unknown = false;
const unsupported = (code: string, message: string) => { unknown = true; finding(code, message); };
if (policy.budget.asset !== 'XRP')
unsupported('ISSUED_ASSET_UNSUPPORTED', 'This first inspector reports XRP only; issued-asset exposure is unknown.');
if (report.permissions.some(p => !SUPPORTED.has(p)))
unsupported('UNSUPPORTED_PERMISSION', 'Live permissions include a type outside this inspector scope.');
if (report.permissions.includes('EscrowFinish'))
unsupported('ESCROW_EXPOSURE_UNSUPPORTED', 'EscrowFinish is delegated; reachable escrow exposure is not modelled.');
if (delegates.some(d => d.Authorize !== policy.accounts.agent))
unsupported('OTHER_DELEGATES', 'Other allowance delegates exist; authority coverage is incomplete.');
if (objects.vault!.some(o => o.LedgerEntryType === 'Delegate'))
unsupported('VAULT_DELEGATION', 'Vault delegation exists outside the supported topology.');
let refill = 0n;
const checks = new Map<string, Json>();
for (const role of ['vault', 'allowance', 'agent'] as const) {
if (info[role]!.RegularKey !== undefined)
unsupported('REGULAR_KEY_COVERAGE', 'A regular key exists; this inspector does not establish alternative signing authority coverage.');
if (Object.keys(info[role]!).some(k => /sponsor/i.test(k)))
unsupported('SPONSORSHIP_UNSUPPORTED', 'Sponsored reserve accounting is not modelled.');
for (const o of objects[role]!) {
const type = o.LedgerEntryType;
if (type === 'Check') {
if (o.Account === policy.accounts.vault && o.Destination === policy.accounts.allowance) {
if (typeof o.index !== 'string')
throw Error('Check missing object index');
checks.set(o.index, o);
}
else if (o.Account === policy.accounts.allowance || o.Destination === policy.accounts.allowance || o.Account === policy.accounts.agent || o.Destination === policy.accounts.agent)
unsupported('OTHER_CHECK', 'A Check exists outside the modelled vault-to-allowance refill path.');
}
else if (type === 'Delegate') {
if (role === 'agent')
unsupported('AGENT_DELEGATION', 'Agent delegates authority outside the supported topology.');
}
else if (type === 'SignerList') {
unsupported('SIGNING_TOPOLOGY_UNSUPPORTED', 'A signer list exists; this inspector does not establish its authority coverage.');
}
else
unsupported('UNSUPPORTED_OBJECT', `Ledger object ${String(type)} is outside the XRP-only inspector scope.`);
}
}
for (const c of checks.values()) {
if (c.Expiration !== undefined && integer(c.Expiration, 'Check expiration') <= report.snapshot.closeTime)
continue;
if (typeof c.SendMax !== 'string') {
unsupported('ISSUED_CHECK_UNSUPPORTED', 'An unexpired issued-asset Check has unknown exposure.');
continue;
}
refill += uint(c.SendMax, 'Check SendMax');
}
// Multiple Checks share the same vault liquidity, which is not reserved by Checks.
refill = refill < balance('vault') ? refill : balance('vault');
report.exposure.refillDrops = unknown ? null : (report.permissions.includes('CheckCash') ? refill : 0n).toString();
report.exposure.reachableDrops = unknown ? null : (!delegate ? 0n : (report.permissions.includes('Payment') ? spendable('allowance') + (report.permissions.includes('CheckCash') ? refill : 0n) : 0n)).toString();
report.complete = !unknown;
}
catch (e) {
finding('INSPECTION_INCOMPLETE', e instanceof Error ? e.message : 'Ledger data unavailable');
report.exposure.reachableDrops = null;
report.exposure.refillDrops = null;
report.complete = false;
}
return report;
}