143 lines
15 KiB
JavaScript
143 lines
15 KiB
JavaScript
// piata.mjs: the dispute and settlement rail of the verifiable infrastructure market (roadmap master point 28, 2026-10-02), on what is
|
|
// live: AereComputeMarketV3 (escrow, provider bond, challenge window, dispute with a challenger bond, arbiter with a permissionless
|
|
// timeout, deadline reclaim), the AIP-23 execution envelope of the provider's result notarized on AereNotary (the chain's time for the
|
|
// delivery, under a post-quantum certified anchor), and the AIP-23 settlement receipt of the terminal payout. This file has three parts:
|
|
// 1. the deployment: the runtime code a market MUST have, rebuilt from the committed artifact and the constructor arguments
|
|
// (`codulAsteptat`), so a reader checks eth_getCode before trusting any event the market emitted;
|
|
// 2. the timeline: every event of a job with its block time and the sender of its transaction (`cronologie`), a JSON a reader can keep;
|
|
// 3. the JUDGE (`judeca`): pure rules over the timeline, no chain: deadlines, the challenge window, who resolved, that the terminal
|
|
// payout matches the state, that the result bytes hash to the on-chain commitment, that a REPLAY of the public spec agrees with
|
|
// whoever the arbiter sided with, and that the provider's notarized envelope names this result and existed before the window
|
|
// closed. Every rule carries a PAZA marker for the planting control. `chitanta` turns the terminal payout into an AIP-23
|
|
// `settlement` envelope, verifiable with the reference verifier.
|
|
// ethers is injected (createRequire of the caller's ethers); the rest is node:crypto and the siblings beside this component.
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { buildProof } from '../proof-kinds/proof-kinds.mjs';
|
|
import { ruleaza, rezultatHash, rezultatOcteti, specHash as specHashOf, keccak, canonical } from './sarcina.mjs';
|
|
|
|
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
|
export const ARTEFACT = path.join(AICI, 'AereComputeMarketV3.json');
|
|
export const STATUS = ['None', 'Open', 'Claimed', 'Submitted', 'Disputed', 'Paid', 'Refunded', 'Slashed'];
|
|
export const MODE = ['REPLAY', 'ZK_VERIFIED', 'OPTIMISTIC'];
|
|
const H32 = /^0x[0-9a-f]{64}$/i;
|
|
const sha256 = (b) => '0x' + crypto.createHash('sha256').update(b).digest('hex');
|
|
|
|
export function incarcaArtefact(f = ARTEFACT) {
|
|
const a = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
if (a.kind !== 'aere-compute-market-v3-artifact' || !a.deployedBytecode || !a.immutables || !a.abi) throw new Error('not a market artifact: ' + f);
|
|
return a;
|
|
}
|
|
/** The runtime code of a deployment: the artifact's deployedBytecode with every immutable written at its positions. */
|
|
export function codulAsteptat(a, args) {
|
|
const cod = Buffer.from(a.deployedBytecode.slice(2), 'hex');
|
|
for (const [nume, im] of Object.entries(a.immutables)) {
|
|
const v = args[nume]; if (v === undefined) throw new Error('missing immutable ' + nume);
|
|
const w = Buffer.alloc(32);
|
|
if (/^address|^contract/.test(im.type)) Buffer.from(v.replace(/^0x/, '').padStart(40, '0'), 'hex').copy(w, 12);
|
|
else if (/^bytes(\d+)$/.test(im.type)) Buffer.from(v.replace(/^0x/, ''), 'hex').copy(w, 0);
|
|
else throw new Error('immutable type not handled: ' + im.type);
|
|
for (const p of im.positions) { if (p.length !== 32) throw new Error('immutable slot not 32 bytes'); w.copy(cod, p.start); }
|
|
}
|
|
return '0x' + cod.toString('hex');
|
|
}
|
|
/** eth_getCode of the market equals the artifact with these constructor arguments; returns { ok, motiv }. */
|
|
export async function verificaDesfasurarea({ provider, market, artifact, args }) {
|
|
const viu = (await provider.getCode(market)).toLowerCase();
|
|
const asteptat = codulAsteptat(artifact, args).toLowerCase();
|
|
if (viu === '0x') return { ok: false, motiv: 'no code at ' + market };
|
|
if (viu !== asteptat) return { ok: false, motiv: `the code at ${market} is not AereComputeMarketV3 with these immutables (${viu.length / 2 - 1} vs ${asteptat.length / 2 - 1} bytes, first difference at byte ${[...viu].findIndex((c, i) => c !== asteptat[i]) >> 1})` };
|
|
return { ok: true, motiv: 'runtime code equals the artifact (' + (viu.length / 2 - 1) + ' bytes) with ZK_VERIFIER, PINNED_PROOF_SELECTOR, ARBITER, GOVERNANCE as given' };
|
|
}
|
|
export const contract = (ethers, provider, market, artifact, signer = null) => new ethers.Contract(market, artifact.abi, signer || provider);
|
|
const s = (v) => (typeof v === 'bigint' ? v.toString() : v);
|
|
export function jobDinLant(j) {
|
|
return { requester: j.requester, provider: j.provider, token: j.token, reward: s(j.reward), providerBond: s(j.providerBond), specHash: j.specHash, resultHash: j.resultHash, zkProgramVKey: j.zkProgramVKey,
|
|
mode: Number(j.mode), status: Number(j.status), pqcSettlement: j.pqcSettlement, offchainResult: j.offchainResult, deadline: Number(j.deadline), challengeWindow: Number(j.challengeWindow), submittedAt: Number(j.submittedAt), disputedAt: Number(j.disputedAt) };
|
|
}
|
|
/** The timeline of a job: its state, its constants, and every event (with block time and tx sender), oldest first. */
|
|
export async function cronologie({ ethers, provider, market, artifact, jobId, fromBlock = 0, spec = null, envelope = null }) {
|
|
const c = contract(ethers, provider, market, artifact);
|
|
const [net, job, arbiter, governance, timeout, zk, pin] = await Promise.all([provider.getNetwork(), c.getJob(jobId), c.ARBITER(), c.GOVERNANCE(), c.ARBITRATION_TIMEOUT(), c.ZK_VERIFIER(), c.PINNED_PROOF_SELECTOR()]);
|
|
const nume = artifact.abi.filter((x) => x.type === 'event' && x.inputs[0] && x.inputs[0].name === 'id').map((x) => x.name);
|
|
const brute = [];
|
|
for (const n of nume) for (const l of await c.queryFilter(c.filters[n](jobId), fromBlock, 'latest')) brute.push(l);
|
|
brute.sort((a, b) => a.blockNumber - b.blockNumber || a.index - b.index);
|
|
const blocuri = new Map(), txs = new Map();
|
|
const events = [];
|
|
for (const l of brute) {
|
|
if (!blocuri.has(l.blockHash)) blocuri.set(l.blockHash, await provider.getBlock(l.blockHash));
|
|
if (!txs.has(l.transactionHash)) txs.set(l.transactionHash, await provider.getTransaction(l.transactionHash));
|
|
const b = blocuri.get(l.blockHash), t = txs.get(l.transactionHash);
|
|
const args = {}; l.fragment.inputs.forEach((inp, i) => { args[inp.name] = s(l.args[i]); });
|
|
events.push({ name: l.fragment.name, args, blockNumber: l.blockNumber, blockHash: l.blockHash, timestamp: b.timestamp, txHash: l.transactionHash, from: t.from, logIndex: l.index });
|
|
}
|
|
const sub = events.find((e) => e.name === 'ResultSubmitted');
|
|
return { chainId: Number(net.chainId), market, jobId: String(jobId), constants: { arbiter, governance, arbitrationTimeout: Number(timeout), zkVerifier: zk, pinnedProofSelector: pin },
|
|
job: jobDinLant(job), events, result: sub ? sub.args.result : null, spec, envelope };
|
|
}
|
|
|
|
// ----- the judge: pure rules over a timeline -----
|
|
const ok = (m, cond, text) => { if (!cond) m.push(text); return !!cond; };
|
|
const low = (x) => String(x || '').toLowerCase();
|
|
export function judeca(d) {
|
|
const motive = [], randuri = []; const j = d.job, ev = d.events || []; const un = (n) => ev.find((e) => e.name === n); const toate = (n) => ev.filter((e) => e.name === n);
|
|
const t = (e) => (e ? e.timestamp : null);
|
|
if (!j || !ev.length) return { verdict: 'INCOMPLETE', motive: ['no job or no events'], randuri };
|
|
for (const e of ev) randuri.push(`${new Date(e.timestamp * 1000).toISOString()} #${e.blockNumber} ${e.name} by ${e.from.slice(0, 10)}.. ${Object.entries(e.args).filter(([k]) => k !== 'result').map(([k, v]) => k + '=' + (H32.test(String(v)) ? String(v).slice(0, 10) + '..' : String(v).length > 20 ? String(v).slice(0, 10) + '..' : v)).join(' ')}`);
|
|
const posted = un('JobPosted'), claimed = un('JobClaimed'), submitted = un('ResultSubmitted') || un('ResultReferenced'), disputed = un('JobDisputed'), resolved = un('DisputeResolved'), timedOut = un('DisputeTimedOut'), paid = un('JobPaid'), refunded = un('JobRefunded'), slashed = un('JobSlashed');
|
|
// R0 the posting matches the job (the reward is compared through the payout events: the contract zeroes j.reward on settlement)
|
|
ok(motive, posted && low(posted.args.requester) === low(j.requester) && low(posted.args.specHash) === low(j.specHash) && Number(posted.args.deadline) === j.deadline && (j.reward === '0' || posted.args.reward === j.reward), 'the JobPosted event does not match the job (requester, reward, specHash, deadline)');
|
|
// R1 deadlines: a claim and a submission only up to the deadline
|
|
if (claimed) ok(motive, t(claimed) <= j.deadline /*PAZA:termen*/, 'the job was claimed after its deadline');
|
|
if (submitted) ok(motive, t(submitted) <= j.deadline && t(submitted) === j.submittedAt /*PAZA:termen*/, 'the result was submitted after the deadline, or submittedAt does not match the event');
|
|
if (refunded && claimed && !submitted && !disputed) ok(motive, t(refunded) > j.deadline /*PAZA:termen*/, 'the escrow was reclaimed as expired before the deadline');
|
|
// R2 the challenge window: a dispute strictly inside it, a finalize (by anyone but the requester) only after it
|
|
if (disputed) ok(motive, submitted && t(disputed) < j.submittedAt + j.challengeWindow /*PAZA:fereastra*/, 'the dispute was raised after the challenge window closed');
|
|
if (paid && !resolved && submitted) ok(motive, low(paid.from) === low(j.requester) || t(paid) >= j.submittedAt + j.challengeWindow /*PAZA:fereastra*/, 'the provider was paid inside the challenge window by someone other than the requester (acceptResult is the requester\'s)');
|
|
// R3 who resolved: the arbiter, or the permissionless timeout after ARBITRATION_TIMEOUT
|
|
if (resolved) ok(motive, low(resolved.from) === low(d.constants.arbiter) /*PAZA:arbitru*/, 'the dispute was resolved by an address that is not the ARBITER');
|
|
if (timedOut) ok(motive, disputed && t(timedOut) >= j.disputedAt + d.constants.arbitrationTimeout /*PAZA:arbitru*/, 'the dispute was timed out before ARBITRATION_TIMEOUT');
|
|
if (resolved || timedOut) ok(motive, !!disputed, 'a resolution without a dispute');
|
|
// R4 the terminal payout matches the state
|
|
const st = STATUS[j.status]; let terminal = null;
|
|
if (st === 'Paid') { const okP = paid && low(paid.args.provider) === low(j.provider) && paid.args.reward === posted.args.reward && (!claimed || paid.args.bondReturned === claimed.args.providerBond) && (!resolved || resolved.args.providerWon === true) /*PAZA:plata*/; ok(motive, okP, 'status Paid without a matching JobPaid (provider, reward, bond returned, providerWon)'); terminal = paid && { status: st, payee: paid.args.provider, amount: paid.args.reward, txHash: paid.txHash, blockHash: paid.blockHash }; }
|
|
else if (st === 'Slashed') { const okS = slashed && disputed && resolved && resolved.args.providerWon === false && low(slashed.args.challenger) === low(disputed.args.challenger) && (!claimed || slashed.args.slashedProviderBond === claimed.args.providerBond) /*PAZA:plata*/; ok(motive, okS, 'status Slashed without a matching JobSlashed (challenger, provider bond) and DisputeResolved(false)'); terminal = slashed && { status: st, payee: slashed.args.challenger, amount: slashed.args.slashedProviderBond, refundedTo: j.requester, txHash: slashed.txHash, blockHash: slashed.blockHash }; }
|
|
else if (st === 'Refunded') { const okR = refunded && low(refunded.args.requester) === low(j.requester) && refunded.args.reward === posted.args.reward /*PAZA:plata*/; ok(motive, okR, 'status Refunded without a matching JobRefunded (requester, reward)'); terminal = refunded && { status: st, payee: refunded.args.requester, amount: refunded.args.reward, txHash: refunded.txHash, blockHash: refunded.blockHash }; }
|
|
ok(motive, j.status !== 5 || j.reward === '0', 'a paid job still holds a reward');
|
|
// R5 the result bytes hash to the on-chain commitment
|
|
if (d.result && submitted) ok(motive, keccak(Buffer.from(d.result.slice(2), 'hex')) === low(j.resultHash) && low(submitted.args.resultHash) === low(j.resultHash) /*PAZA:rezultat*/, 'the submitted bytes do not hash to the job\'s resultHash');
|
|
// R6 REPLAY: the public spec recomputed; the arbiter's decision must agree with the recomputation
|
|
let replay = null;
|
|
if (d.spec && d.result && submitted) {
|
|
ok(motive, specHashOf(d.spec) === low(j.specHash), 'the public spec does not hash to the job\'s specHash');
|
|
try { const r = ruleaza(d.spec); const corect = rezultatHash(r) === low(j.resultHash); replay = { recomputedHash: rezultatHash(r), providerRight: corect };
|
|
if (disputed) { replay.challengerRecomputedRight = low(disputed.args.recomputedResultHash) === rezultatHash(r); }
|
|
if (resolved) ok(motive, resolved.args.providerWon === corect /*PAZA:replay*/, `the arbiter sided with the ${resolved.args.providerWon ? 'provider' : 'challenger'} but the replay says the provider is ${corect ? 'right' : 'wrong'}`);
|
|
if (st === 'Paid' && !resolved) ok(motive, corect /*PAZA:replay*/, 'the provider was paid for a result the replay does not reproduce (no one disputed in time)');
|
|
} catch (e) { motive.push('the replay could not run: ' + e.message); }
|
|
}
|
|
// R7 the provider's envelope names this result and existed before the window closed
|
|
if (d.envelope) {
|
|
const e = d.envelope, stmt = e.statement || {};
|
|
const okE = e.kind === 'aere-proof-of-execution-attestation' && low(stmt.outputHash) === low(j.resultHash) && low(stmt.inputHash) === low(j.specHash) && sha256(Buffer.from(JSON.stringify(stmt), 'utf8')) === low(e.statementHash) /*PAZA:dovada*/;
|
|
ok(motive, okE, 'the execution envelope does not name this job\'s specHash/resultHash, or its statementHash is not its statement\'s');
|
|
if (e.notarization && submitted) ok(motive, Number(e.notarization.firstSeen) <= j.submittedAt + j.challengeWindow /*PAZA:dovada*/, 'the envelope was notarized only after the challenge window closed');
|
|
}
|
|
const terminalStatus = ['Paid', 'Refunded', 'Slashed'].includes(st);
|
|
return { verdict: motive.length ? 'INVALID' : terminalStatus ? 'VALID' : 'INCOMPLETE', motive, randuri, status: st, terminal, replay };
|
|
}
|
|
/** The AIP-23 settlement receipt of a terminal job: the payout transaction, the parties, the amount, the instruction (market, job, status). */
|
|
export function chitanta(d, { createdAt = new Date().toISOString() } = {}) {
|
|
const r = judeca(d); if (r.verdict !== 'VALID' || !r.terminal) throw new Error('no valid terminal settlement to receipt: ' + (r.motive[0] || r.verdict));
|
|
const t = r.terminal;
|
|
return buildProof('settlement', { chainId: d.chainId, txHash: t.txHash, blockHash: t.blockHash, from: d.market, to: t.payee, amount: t.amount, asset: 'native', instruction: Buffer.from(canonical({ market: d.market, jobId: d.jobId, status: t.status, resultHash: d.job.resultHash }), 'utf8'), createdAt });
|
|
}
|
|
/** The provider's execution envelope for a result (spec in, result out), to notarize before submitting. */
|
|
export function plicExecutie({ spec, result, programSha256, createdAt = new Date().toISOString() }) {
|
|
return buildProof('execution', { program: spec.task, inputHash: specHashOf(spec), outputHash: rezultatHash(result), programSha256, exitCode: 0, createdAt });
|
|
}
|
|
export { ruleaza, rezultatHash, rezultatOcteti, specHashOf as specHash, canonical };
|