diff --git a/scientific-bounty-system/team-payout-split-ledger/README.md b/scientific-bounty-system/team-payout-split-ledger/README.md new file mode 100644 index 00000000..c4d71232 --- /dev/null +++ b/scientific-bounty-system/team-payout-split-ledger/README.md @@ -0,0 +1,37 @@ +# Team Payout Split Ledger + +This slice supports issue #18, Scientific Bounty System. It adds a deterministic +ledger for allocating accepted scientific bounty awards across teams, +institutions, and milestone deliverables. + +The module uses synthetic challenge data only. It does not process payment +credentials, identity documents, tax records, or banking details. Instead, it +tracks privacy-safe payout route states such as `verified`, `pending`, and +`blocked`. + +## Checks + +- Locked milestones cannot release money until accepted. +- Contributor splits must total 10000 basis points. +- Institution overhead is capped per challenge policy. +- Every contributor must keep a minimum solver share after overhead. +- Payout routes must be verified before the release is marked payable. +- Generated receipts preserve the award basis, hold reasons, and next actions. + +## Local Verification + +```bash +node scientific-bounty-system/team-payout-split-ledger/test.js +node scientific-bounty-system/team-payout-split-ledger/demo.js +git diff --check +``` + +The demo writes deterministic artifacts under +`scientific-bounty-system/team-payout-split-ledger/artifacts/`. + +## Files + +- `ledger.js` - allocation, policy checks, and receipt generation. +- `data/challenges.json` - synthetic bounty challenge packets. +- `test.js` - Node built-in tests. +- `demo.js` - deterministic JSON, Markdown, and SVG artifact generation. diff --git a/scientific-bounty-system/team-payout-split-ledger/artifacts/payout-flow.svg b/scientific-bounty-system/team-payout-split-ledger/artifacts/payout-flow.svg new file mode 100644 index 00000000..7a6fa161 --- /dev/null +++ b/scientific-bounty-system/team-payout-split-ledger/artifacts/payout-flow.svg @@ -0,0 +1,17 @@ + + + Team Payout Split Ledger + + + Accepted milestones + release eligible funds + + + Contributor splits + overhead and route checks + + + Payable 1 / Blocked 2 + Held 2, locked $400.00 + + diff --git a/scientific-bounty-system/team-payout-split-ledger/artifacts/payout-ledger.json b/scientific-bounty-system/team-payout-split-ledger/artifacts/payout-ledger.json new file mode 100644 index 00000000..10c6167f --- /dev/null +++ b/scientific-bounty-system/team-payout-split-ledger/artifacts/payout-ledger.json @@ -0,0 +1,200 @@ +{ + "summary": { + "total": 3, + "payable": 1, + "blocked": 2, + "held": 2, + "unlockedCents": 230000, + "lockedCents": 40000 + }, + "ledgers": [ + { + "challengeId": "bio-marker-panel", + "title": "Biomarker validation panel", + "currency": "USD", + "totalPrizeCents": 120000, + "unlockedAmountCents": 80000, + "lockedAmountCents": 40000, + "payable": true, + "allocations": [ + { + "contributorId": "solver-alpha", + "role": "analysis lead", + "payoutRouteStatus": "verified", + "grossCents": 44000, + "institutionCents": 8000, + "solverCents": 36000 + }, + { + "contributorId": "solver-beta", + "role": "wet-lab validation", + "payoutRouteStatus": "verified", + "grossCents": 24000, + "institutionCents": 4000, + "solverCents": 20000 + }, + { + "contributorId": "solver-gamma", + "role": "reproducibility reviewer", + "payoutRouteStatus": "verified", + "grossCents": 12000, + "institutionCents": 0, + "solverCents": 12000 + } + ], + "findings": [ + { + "code": "milestone_locked", + "severity": "hold", + "target": "replication", + "message": "replication is pending_sponsor_review; funds stay locked until sponsor acceptance.", + "action": "Wait for sponsor acceptance or revise the deliverable packet." + } + ], + "receipt": { + "awardBasis": "2 accepted milestone(s), 1 locked milestone(s)", + "releaseStatus": "ready_for_sponsor_review", + "acceptedMilestones": [ + "proposal", + "prototype" + ], + "lockedMilestones": [ + "replication" + ], + "totalAllocatedCents": 80000, + "nextActions": [ + "Wait for sponsor acceptance or revise the deliverable packet." + ] + } + }, + { + "challengeId": "climate-forecast", + "title": "Regional climate forecast benchmark", + "currency": "USD", + "totalPrizeCents": 90000, + "unlockedAmountCents": 90000, + "lockedAmountCents": 0, + "payable": false, + "allocations": [ + { + "contributorId": "solver-delta", + "role": "model owner", + "payoutRouteStatus": "verified", + "grossCents": 63000, + "institutionCents": 22500, + "solverCents": 40500 + }, + { + "contributorId": "solver-epsilon", + "role": "data engineer", + "payoutRouteStatus": "pending", + "grossCents": 22500, + "institutionCents": 0, + "solverCents": 22500 + } + ], + "findings": [ + { + "code": "payout_route_unverified", + "severity": "block", + "target": "solver-epsilon", + "message": "solver-epsilon has payout route status pending.", + "action": "Hold release for this contributor until payout route verification is complete." + }, + { + "code": "split_total_mismatch", + "severity": "block", + "target": "climate-forecast", + "message": "Contributor splits total 9500 basis points instead of 10000.", + "action": "Rebalance contributor splits before payout approval." + }, + { + "code": "institution_overhead_exceeds_cap", + "severity": "block", + "target": "solver-delta", + "message": "solver-delta routes 2500 basis points to an institution, above the 1200 cap.", + "action": "Lower institution overhead or attach sponsor-approved exception evidence." + } + ], + "receipt": { + "awardBasis": "2 accepted milestone(s), 0 locked milestone(s)", + "releaseStatus": "blocked", + "acceptedMilestones": [ + "proposal", + "forecast-model" + ], + "lockedMilestones": [], + "totalAllocatedCents": 85500, + "nextActions": [ + "Hold release for this contributor until payout route verification is complete.", + "Rebalance contributor splits before payout approval.", + "Lower institution overhead or attach sponsor-approved exception evidence." + ] + } + }, + { + "challengeId": "quantum-noise", + "title": "Quantum noise reduction challenge", + "currency": "USD", + "totalPrizeCents": 60000, + "unlockedAmountCents": 60000, + "lockedAmountCents": 0, + "payable": false, + "allocations": [ + { + "contributorId": "solver-zeta", + "role": "principal investigator", + "payoutRouteStatus": "verified", + "grossCents": 54000, + "institutionCents": 48000, + "solverCents": 6000 + }, + { + "contributorId": "solver-eta", + "role": "experiment reviewer", + "payoutRouteStatus": "verified", + "grossCents": 6000, + "institutionCents": 0, + "solverCents": 6000 + } + ], + "findings": [ + { + "code": "institution_overhead_exceeds_cap", + "severity": "block", + "target": "solver-zeta", + "message": "solver-zeta routes 8000 basis points to an institution, above the 1500 cap.", + "action": "Lower institution overhead or attach sponsor-approved exception evidence." + }, + { + "code": "solver_share_below_minimum", + "severity": "hold", + "target": "solver-zeta", + "message": "solver-zeta keeps 1000 basis points after overhead, below the 1200 minimum.", + "action": "Increase direct solver share or move institution recovery outside the bounty payout." + }, + { + "code": "solver_share_below_minimum", + "severity": "hold", + "target": "solver-eta", + "message": "solver-eta keeps 1000 basis points after overhead, below the 1200 minimum.", + "action": "Increase direct solver share or move institution recovery outside the bounty payout." + } + ], + "receipt": { + "awardBasis": "2 accepted milestone(s), 0 locked milestone(s)", + "releaseStatus": "blocked", + "acceptedMilestones": [ + "proposal", + "prototype" + ], + "lockedMilestones": [], + "totalAllocatedCents": 60000, + "nextActions": [ + "Lower institution overhead or attach sponsor-approved exception evidence.", + "Increase direct solver share or move institution recovery outside the bounty payout." + ] + } + } + ] +} diff --git a/scientific-bounty-system/team-payout-split-ledger/artifacts/payout-ledger.md b/scientific-bounty-system/team-payout-split-ledger/artifacts/payout-ledger.md new file mode 100644 index 00000000..79964866 --- /dev/null +++ b/scientific-bounty-system/team-payout-split-ledger/artifacts/payout-ledger.md @@ -0,0 +1,44 @@ +# Team Payout Split Ledger + +## Summary + +- Challenges reviewed: 3 +- Payable ledgers: 1 +- Blocked ledgers: 2 +- Held ledgers: 2 +- Unlocked funds: $2300.00 +- Locked funds: $400.00 + +## Challenge Ledgers + +### bio-marker-panel + +- Title: Biomarker validation panel +- Payable: true +- Unlocked: $800.00 +- Locked: $400.00 +- Receipt status: ready_for_sponsor_review + - hold: milestone_locked - replication is pending_sponsor_review; funds stay locked until sponsor acceptance. + +### climate-forecast + +- Title: Regional climate forecast benchmark +- Payable: false +- Unlocked: $900.00 +- Locked: $0.00 +- Receipt status: blocked + - block: payout_route_unverified - solver-epsilon has payout route status pending. + - block: split_total_mismatch - Contributor splits total 9500 basis points instead of 10000. + - block: institution_overhead_exceeds_cap - solver-delta routes 2500 basis points to an institution, above the 1200 cap. + +### quantum-noise + +- Title: Quantum noise reduction challenge +- Payable: false +- Unlocked: $600.00 +- Locked: $0.00 +- Receipt status: blocked + - block: institution_overhead_exceeds_cap - solver-zeta routes 8000 basis points to an institution, above the 1500 cap. + - hold: solver_share_below_minimum - solver-zeta keeps 1000 basis points after overhead, below the 1200 minimum. + - hold: solver_share_below_minimum - solver-eta keeps 1000 basis points after overhead, below the 1200 minimum. + diff --git a/scientific-bounty-system/team-payout-split-ledger/data/challenges.json b/scientific-bounty-system/team-payout-split-ledger/data/challenges.json new file mode 100644 index 00000000..6bff8856 --- /dev/null +++ b/scientific-bounty-system/team-payout-split-ledger/data/challenges.json @@ -0,0 +1,100 @@ +[ + { + "challengeId": "bio-marker-panel", + "title": "Biomarker validation panel", + "currency": "USD", + "totalPrizeCents": 120000, + "policy": { + "maxInstitutionOverheadBasisPoints": 1500, + "minSolverShareBasisPoints": 1000 + }, + "milestones": [ + { "id": "proposal", "status": "accepted", "amountCents": 30000 }, + { "id": "prototype", "status": "accepted", "amountCents": 50000 }, + { "id": "replication", "status": "pending_sponsor_review", "amountCents": 40000 } + ], + "contributors": [ + { + "id": "solver-alpha", + "role": "analysis lead", + "splitBasisPoints": 5500, + "institutionOverheadBasisPoints": 1000, + "payoutRouteStatus": "verified" + }, + { + "id": "solver-beta", + "role": "wet-lab validation", + "splitBasisPoints": 3000, + "institutionOverheadBasisPoints": 500, + "payoutRouteStatus": "verified" + }, + { + "id": "solver-gamma", + "role": "reproducibility reviewer", + "splitBasisPoints": 1500, + "institutionOverheadBasisPoints": 0, + "payoutRouteStatus": "verified" + } + ] + }, + { + "challengeId": "climate-forecast", + "title": "Regional climate forecast benchmark", + "currency": "USD", + "totalPrizeCents": 90000, + "policy": { + "maxInstitutionOverheadBasisPoints": 1200, + "minSolverShareBasisPoints": 800 + }, + "milestones": [ + { "id": "proposal", "status": "accepted", "amountCents": 20000 }, + { "id": "forecast-model", "status": "accepted", "amountCents": 70000 } + ], + "contributors": [ + { + "id": "solver-delta", + "role": "model owner", + "splitBasisPoints": 7000, + "institutionOverheadBasisPoints": 2500, + "payoutRouteStatus": "verified" + }, + { + "id": "solver-epsilon", + "role": "data engineer", + "splitBasisPoints": 2500, + "institutionOverheadBasisPoints": 0, + "payoutRouteStatus": "pending" + } + ] + }, + { + "challengeId": "quantum-noise", + "title": "Quantum noise reduction challenge", + "currency": "USD", + "totalPrizeCents": 60000, + "policy": { + "maxInstitutionOverheadBasisPoints": 1500, + "minSolverShareBasisPoints": 1200 + }, + "milestones": [ + { "id": "proposal", "status": "accepted", "amountCents": 25000 }, + { "id": "prototype", "status": "accepted", "amountCents": 35000 } + ], + "contributors": [ + { + "id": "solver-zeta", + "role": "principal investigator", + "splitBasisPoints": 9000, + "institutionOverheadBasisPoints": 8000, + "payoutRouteStatus": "verified" + }, + { + "id": "solver-eta", + "role": "experiment reviewer", + "splitBasisPoints": 1000, + "institutionOverheadBasisPoints": 0, + "payoutRouteStatus": "verified" + } + ] + } +] diff --git a/scientific-bounty-system/team-payout-split-ledger/demo.js b/scientific-bounty-system/team-payout-split-ledger/demo.js new file mode 100644 index 00000000..bfd15bfd --- /dev/null +++ b/scientific-bounty-system/team-payout-split-ledger/demo.js @@ -0,0 +1,77 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const challenges = require("./data/challenges.json"); +const { buildPayoutLedgers, summarizeLedgers } = require("./ledger"); + +const artifactDir = path.join(__dirname, "artifacts"); +const ledgers = buildPayoutLedgers(challenges); +const summary = summarizeLedgers(ledgers); + +fs.mkdirSync(artifactDir, { recursive: true }); +fs.writeFileSync(path.join(artifactDir, "payout-ledger.json"), `${JSON.stringify({ summary, ledgers }, null, 2)}\n`); +fs.writeFileSync(path.join(artifactDir, "payout-ledger.md"), renderMarkdown(summary, ledgers)); +fs.writeFileSync(path.join(artifactDir, "payout-flow.svg"), renderSvg(summary)); + +console.log(`reviewed ${summary.total} bounty payout ledgers`); +console.log(`payable=${summary.payable} blocked=${summary.blocked} held=${summary.held}`); + +function renderMarkdown(reportSummary, ledgerRows) { + const lines = [ + "# Team Payout Split Ledger", + "", + "## Summary", + "", + `- Challenges reviewed: ${reportSummary.total}`, + `- Payable ledgers: ${reportSummary.payable}`, + `- Blocked ledgers: ${reportSummary.blocked}`, + `- Held ledgers: ${reportSummary.held}`, + `- Unlocked funds: ${formatCurrency(reportSummary.unlockedCents)}`, + `- Locked funds: ${formatCurrency(reportSummary.lockedCents)}`, + "", + "## Challenge Ledgers", + "", + ]; + + for (const ledger of ledgerRows) { + lines.push(`### ${ledger.challengeId}`); + lines.push(""); + lines.push(`- Title: ${ledger.title}`); + lines.push(`- Payable: ${ledger.payable}`); + lines.push(`- Unlocked: ${formatCurrency(ledger.unlockedAmountCents)}`); + lines.push(`- Locked: ${formatCurrency(ledger.lockedAmountCents)}`); + lines.push(`- Receipt status: ${ledger.receipt.releaseStatus}`); + for (const finding of ledger.findings) { + lines.push(` - ${finding.severity}: ${finding.code} - ${finding.message}`); + } + lines.push(""); + } + + return `${lines.join("\n")}\n`; +} + +function renderSvg(reportSummary) { + return ` + + Team Payout Split Ledger + + + Accepted milestones + release eligible funds + + + Contributor splits + overhead and route checks + + + Payable ${reportSummary.payable} / Blocked ${reportSummary.blocked} + Held ${reportSummary.held}, locked ${formatCurrency(reportSummary.lockedCents)} + + +`; +} + +function formatCurrency(cents) { + return `$${(cents / 100).toFixed(2)}`; +} diff --git a/scientific-bounty-system/team-payout-split-ledger/ledger.js b/scientific-bounty-system/team-payout-split-ledger/ledger.js new file mode 100644 index 00000000..4e1bfe29 --- /dev/null +++ b/scientific-bounty-system/team-payout-split-ledger/ledger.js @@ -0,0 +1,202 @@ +"use strict"; + +const BASIS_POINTS = 10000; + +function buildPayoutLedgers(challenges) { + return challenges.map(buildPayoutLedger); +} + +function buildPayoutLedger(challenge) { + validateChallengeShape(challenge); + + const acceptedMilestones = challenge.milestones.filter((milestone) => milestone.status === "accepted"); + const lockedMilestones = challenge.milestones.filter((milestone) => milestone.status !== "accepted"); + const unlockedAmountCents = acceptedMilestones.reduce( + (total, milestone) => total + milestone.amountCents, + 0, + ); + + const routeFindings = reviewRoutes(challenge); + const splitFindings = reviewSplits(challenge); + const overheadFindings = reviewInstitutionOverhead(challenge); + const solverShareFindings = reviewMinimumSolverShare(challenge); + const milestoneFindings = lockedMilestones.map((milestone) => + finding( + "milestone_locked", + "hold", + milestone.id, + `${milestone.id} is ${milestone.status}; funds stay locked until sponsor acceptance.`, + "Wait for sponsor acceptance or revise the deliverable packet.", + ), + ); + + const allocations = allocateContributors(challenge, unlockedAmountCents); + const findings = [ + ...milestoneFindings, + ...routeFindings, + ...splitFindings, + ...overheadFindings, + ...solverShareFindings, + ]; + const payable = findings.every((item) => item.severity !== "block") && allocations.length > 0; + + return { + challengeId: challenge.challengeId, + title: challenge.title, + currency: challenge.currency, + totalPrizeCents: challenge.totalPrizeCents, + unlockedAmountCents, + lockedAmountCents: challenge.totalPrizeCents - unlockedAmountCents, + payable, + allocations, + findings, + receipt: buildReceipt(challenge, allocations, findings, acceptedMilestones, lockedMilestones), + }; +} + +function validateChallengeShape(challenge) { + if (!challenge.challengeId || !challenge.title) { + throw new Error("Challenge must include challengeId and title."); + } + if (!Number.isInteger(challenge.totalPrizeCents) || challenge.totalPrizeCents <= 0) { + throw new Error(`${challenge.challengeId} must include a positive totalPrizeCents.`); + } + if (!Array.isArray(challenge.milestones) || challenge.milestones.length === 0) { + throw new Error(`${challenge.challengeId} must include milestones.`); + } + if (!Array.isArray(challenge.contributors) || challenge.contributors.length === 0) { + throw new Error(`${challenge.challengeId} must include contributors.`); + } +} + +function reviewRoutes(challenge) { + return challenge.contributors + .filter((contributor) => contributor.payoutRouteStatus !== "verified") + .map((contributor) => + finding( + "payout_route_unverified", + "block", + contributor.id, + `${contributor.id} has payout route status ${contributor.payoutRouteStatus}.`, + "Hold release for this contributor until payout route verification is complete.", + ), + ); +} + +function reviewSplits(challenge) { + const splitTotal = challenge.contributors.reduce( + (total, contributor) => total + contributor.splitBasisPoints, + 0, + ); + if (splitTotal === BASIS_POINTS) { + return []; + } + + return [ + finding( + "split_total_mismatch", + "block", + challenge.challengeId, + `Contributor splits total ${splitTotal} basis points instead of ${BASIS_POINTS}.`, + "Rebalance contributor splits before payout approval.", + ), + ]; +} + +function reviewInstitutionOverhead(challenge) { + return challenge.contributors.flatMap((contributor) => { + const overheadBasisPoints = contributor.institutionOverheadBasisPoints ?? 0; + if (overheadBasisPoints <= challenge.policy.maxInstitutionOverheadBasisPoints) { + return []; + } + + return [ + finding( + "institution_overhead_exceeds_cap", + "block", + contributor.id, + `${contributor.id} routes ${overheadBasisPoints} basis points to an institution, above the ${challenge.policy.maxInstitutionOverheadBasisPoints} cap.`, + "Lower institution overhead or attach sponsor-approved exception evidence.", + ), + ]; + }); +} + +function reviewMinimumSolverShare(challenge) { + return challenge.contributors.flatMap((contributor) => { + const overheadBasisPoints = contributor.institutionOverheadBasisPoints ?? 0; + const solverShareBasisPoints = contributor.splitBasisPoints - overheadBasisPoints; + if (solverShareBasisPoints >= challenge.policy.minSolverShareBasisPoints) { + return []; + } + + return [ + finding( + "solver_share_below_minimum", + "hold", + contributor.id, + `${contributor.id} keeps ${solverShareBasisPoints} basis points after overhead, below the ${challenge.policy.minSolverShareBasisPoints} minimum.`, + "Increase direct solver share or move institution recovery outside the bounty payout.", + ), + ]; + }); +} + +function allocateContributors(challenge, unlockedAmountCents) { + if (unlockedAmountCents === 0) { + return []; + } + + return challenge.contributors.map((contributor) => { + const grossCents = Math.round((unlockedAmountCents * contributor.splitBasisPoints) / BASIS_POINTS); + const institutionOverheadBasisPoints = contributor.institutionOverheadBasisPoints ?? 0; + const institutionCents = Math.round((unlockedAmountCents * institutionOverheadBasisPoints) / BASIS_POINTS); + const solverCents = grossCents - institutionCents; + + return { + contributorId: contributor.id, + role: contributor.role, + payoutRouteStatus: contributor.payoutRouteStatus, + grossCents, + institutionCents, + solverCents, + }; + }); +} + +function buildReceipt(challenge, allocations, findings, acceptedMilestones, lockedMilestones) { + return { + awardBasis: `${acceptedMilestones.length} accepted milestone(s), ${lockedMilestones.length} locked milestone(s)`, + releaseStatus: findings.some((item) => item.severity === "block") ? "blocked" : "ready_for_sponsor_review", + acceptedMilestones: acceptedMilestones.map((milestone) => milestone.id), + lockedMilestones: lockedMilestones.map((milestone) => milestone.id), + totalAllocatedCents: allocations.reduce((total, allocation) => total + allocation.grossCents, 0), + nextActions: Array.from(new Set(findings.map((item) => item.action))), + }; +} + +function finding(code, severity, target, message, action) { + return { code, severity, target, message, action }; +} + +function summarizeLedgers(ledgers) { + return ledgers.reduce( + (summary, ledger) => { + summary.total += 1; + summary.payable += ledger.payable ? 1 : 0; + summary.blocked += ledger.findings.some((item) => item.severity === "block") ? 1 : 0; + summary.held += ledger.findings.some((item) => item.severity === "hold") ? 1 : 0; + summary.unlockedCents += ledger.unlockedAmountCents; + summary.lockedCents += ledger.lockedAmountCents; + return summary; + }, + { total: 0, payable: 0, blocked: 0, held: 0, unlockedCents: 0, lockedCents: 0 }, + ); +} + +module.exports = { + BASIS_POINTS, + buildPayoutLedger, + buildPayoutLedgers, + summarizeLedgers, +}; diff --git a/scientific-bounty-system/team-payout-split-ledger/test.js b/scientific-bounty-system/team-payout-split-ledger/test.js new file mode 100644 index 00000000..361b6086 --- /dev/null +++ b/scientific-bounty-system/team-payout-split-ledger/test.js @@ -0,0 +1,53 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const challenges = require("./data/challenges.json"); +const { buildPayoutLedger, buildPayoutLedgers, summarizeLedgers } = require("./ledger"); + +test("allocates accepted milestone funds across verified team members", () => { + const ledger = buildPayoutLedger(challenges[0]); + + assert.equal(ledger.unlockedAmountCents, 80000); + assert.equal(ledger.lockedAmountCents, 40000); + assert.equal(ledger.payable, true); + assert.deepEqual( + ledger.allocations.map((allocation) => allocation.grossCents), + [44000, 24000, 12000], + ); + assert.equal(ledger.findings.length, 1); + assert.equal(ledger.findings[0].code, "milestone_locked"); +}); + +test("blocks release when route verification and split totals are invalid", () => { + const ledger = buildPayoutLedger(challenges[1]); + const codes = ledger.findings.map((item) => item.code); + + assert.equal(ledger.payable, false); + assert.ok(codes.includes("payout_route_unverified")); + assert.ok(codes.includes("split_total_mismatch")); + assert.ok(codes.includes("institution_overhead_exceeds_cap")); +}); + +test("holds allocations when institution overhead leaves too little direct solver share", () => { + const ledger = buildPayoutLedger(challenges[2]); + const solverShareFinding = ledger.findings.find((item) => item.code === "solver_share_below_minimum"); + + assert.equal(ledger.payable, false); + assert.equal(solverShareFinding.severity, "hold"); + assert.equal(solverShareFinding.target, "solver-zeta"); +}); + +test("summarizes payout readiness across challenge ledgers", () => { + const ledgers = buildPayoutLedgers(challenges); + const summary = summarizeLedgers(ledgers); + + assert.deepEqual(summary, { + total: 3, + payable: 1, + blocked: 2, + held: 2, + unlockedCents: 230000, + lockedCents: 40000, + }); +});