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 @@
+
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 `
+`;
+}
+
+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,
+ });
+});