diff --git a/README.md b/README.md index d338cf6..58d9f2e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # deepevents.ai deepevents.ai main codebase + +## Scientific Bounty System Modules + +- [Bounty Payout Eligibility Gate](bounty-payout-eligibility-gate/README.md): solver/team payout readiness checks for country support, payout method evidence, tax and institution routing, sanctions risk, IP release guardrails, remediation actions, and signed audit packets. diff --git a/bounty-payout-eligibility-gate/README.md b/bounty-payout-eligibility-gate/README.md new file mode 100644 index 0000000..dd52c99 --- /dev/null +++ b/bounty-payout-eligibility-gate/README.md @@ -0,0 +1,30 @@ +# Bounty Payout Eligibility Gate + +Dependency-free Scientific Bounty System module that evaluates whether a solver team can safely move from reward decision to payout settlement. + +This slice focuses on payout eligibility and compliance readiness, which is separate from challenge intake, scoring, arbitration, appeals, anti-collusion, escrow settlement, sponsor reliability, and amendment consent modules. + +## What it does + +- Checks contributor identity attestations and supported payout countries. +- Validates payout method, tax form, and institution routing evidence. +- Flags sanctions/watchlist risk and unresolved compliance notes. +- Keeps IP release blocked until the team is eligible and settlement is funded. +- Produces sponsor-facing remediation actions by contributor. +- Emits a signed audit packet for payout review. + +All examples are synthetic and no external services, credentials, or personal data are required. + +## Run + +```bash +npm run check +npm test +npm run demo +``` + +The demo reads `sample-data.json` and prints a deterministic settlement readiness packet. + +## Demo Video + +`docs/demo.mp4` is a short H.264 walkthrough artifact generated from `docs/demo.svg`. diff --git a/bounty-payout-eligibility-gate/demo.js b/bounty-payout-eligibility-gate/demo.js new file mode 100644 index 0000000..a61bc53 --- /dev/null +++ b/bounty-payout-eligibility-gate/demo.js @@ -0,0 +1,25 @@ +const sample = require("./sample-data.json"); +const { buildPayoutReadinessPacket, summarizeForSponsor } = require("./src/eligibilityGate"); + +const packet = buildPayoutReadinessPacket(sample); +const sponsorSummary = summarizeForSponsor(packet); + +console.log("Bounty payout eligibility gate demo"); +console.log("------------------------------------"); +console.log(`Challenge: ${packet.challengeTitle}`); +console.log(`Team: ${packet.teamName}`); +console.log(`Bounty: $${packet.bountyAmountUsd}`); +console.log(`Status: ${packet.status}`); +console.log(`Settlement recommendation: ${sponsorSummary.settlementRecommendation}`); +console.log(`IP release: ${packet.ipRelease}`); +console.log(`Audit digest: ${packet.auditDigest}`); +console.log(""); +console.log("Payout plan:"); +for (const entry of packet.payoutPlan) { + console.log(`- ${entry.displayName}: ${entry.percent}% / $${entry.amountUsd} / ${entry.routingStatus}`); +} +console.log(""); +console.log("Remediation actions:"); +for (const item of packet.remediationActions) { + console.log(`- [${item.severity}] ${item.owner}: ${item.message}`); +} diff --git a/bounty-payout-eligibility-gate/docs/demo.mp4 b/bounty-payout-eligibility-gate/docs/demo.mp4 new file mode 100644 index 0000000..22a5b43 Binary files /dev/null and b/bounty-payout-eligibility-gate/docs/demo.mp4 differ diff --git a/bounty-payout-eligibility-gate/docs/demo.svg b/bounty-payout-eligibility-gate/docs/demo.svg new file mode 100644 index 0000000..ce7182b --- /dev/null +++ b/bounty-payout-eligibility-gate/docs/demo.svg @@ -0,0 +1,46 @@ + + Bounty payout eligibility gate demo + Diagram showing challenge reward, payout eligibility checks, remediation actions, and signed audit packet. + + + Bounty Payout Eligibility Gate + Scientific Bounty System payout readiness before settlement release + + + + Reward Decision + $1,000 bounty funded + Team split: 70 / 30 + + + + + Eligibility Checks + Identity attestation + Supported payout country + Tax and institution routing + Watchlist and IP release guards + + + + + Status: Blocked + Missing payout method + Missing tax form + + + + + + + + Signed Audit Packet + Sponsor receives remediation actions, payout plan, IP release status, and deterministic SHA-256 audit digest. + + + + + + + + diff --git a/bounty-payout-eligibility-gate/docs/requirement-map.md b/bounty-payout-eligibility-gate/docs/requirement-map.md new file mode 100644 index 0000000..8c3844a --- /dev/null +++ b/bounty-payout-eligibility-gate/docs/requirement-map.md @@ -0,0 +1,20 @@ +# Requirement Map + +This module maps to issue #18, Scientific Bounty System. + +| Issue capability | Implementation | +| --- | --- | +| Reward distribution | Builds contributor-level payout plan from award splits and bounty amount. | +| Payout routing | Checks payout method evidence, country support, tax form status, and institution routing approval. | +| Escrowed prize funds | Blocks settlement if sponsor funds are not confirmed. | +| Team and institution payouts | Supports contributor splits plus institution approval holds. | +| IP management options | Keeps IP release blocked until payout eligibility and funding are ready. | +| Platform-mediated arbitration | Emits signed audit packets and sponsor-facing remediation actions for payout review. | +| Secure solver participation | Flags identity attestation and watchlist review blockers before funds move. | + +## Acceptance Evidence + +- `npm run check` +- `npm test` +- `npm run demo` +- `docs/demo.mp4` diff --git a/bounty-payout-eligibility-gate/package.json b/bounty-payout-eligibility-gate/package.json new file mode 100644 index 0000000..5a08a94 --- /dev/null +++ b/bounty-payout-eligibility-gate/package.json @@ -0,0 +1,13 @@ +{ + "name": "bounty-payout-eligibility-gate", + "version": "1.0.0", + "description": "Payout eligibility and compliance readiness gate for scientific bounty teams.", + "type": "commonjs", + "private": true, + "scripts": { + "check": "node --check src/eligibilityGate.js && node --check test.js && node --check demo.js", + "test": "node test.js", + "demo": "node demo.js" + }, + "license": "MIT" +} diff --git a/bounty-payout-eligibility-gate/sample-data.json b/bounty-payout-eligibility-gate/sample-data.json new file mode 100644 index 0000000..9eb6de7 --- /dev/null +++ b/bounty-payout-eligibility-gate/sample-data.json @@ -0,0 +1,55 @@ +{ + "challenge": { + "id": "CH-2026-BIOMARKER-042", + "title": "Single-cell biomarker discovery challenge", + "bountyAmountUsd": 1000, + "settlementFunded": true, + "ipPolicy": "solver-retains-until-paid", + "sponsor": "Northstar Bioinformatics Lab" + }, + "team": { + "id": "TEAM-ALPHA", + "name": "Open Reproducibility Alpha", + "awardSplits": [ + { + "contributorId": "solver-1", + "percent": 70 + }, + { + "contributorId": "solver-2", + "percent": 30 + } + ], + "contributors": [ + { + "id": "solver-1", + "displayName": "Dr. A. Rivera", + "country": "US", + "identityAttested": true, + "payoutMethodVerified": true, + "taxFormStatus": "valid", + "institutionRouting": { + "required": false, + "approved": false + }, + "watchlistStatus": "clear", + "ipAssignmentConsent": true + }, + { + "id": "solver-2", + "displayName": "M. Chen", + "country": "CA", + "identityAttested": true, + "payoutMethodVerified": false, + "taxFormStatus": "missing", + "institutionRouting": { + "required": true, + "approved": false, + "institution": "Example University" + }, + "watchlistStatus": "clear", + "ipAssignmentConsent": true + } + ] + } +} diff --git a/bounty-payout-eligibility-gate/src/eligibilityGate.js b/bounty-payout-eligibility-gate/src/eligibilityGate.js new file mode 100644 index 0000000..daa6c9c --- /dev/null +++ b/bounty-payout-eligibility-gate/src/eligibilityGate.js @@ -0,0 +1,315 @@ +const crypto = require("crypto"); + +const DEFAULT_SUPPORTED_COUNTRIES = new Set([ + "AT", + "AU", + "BE", + "BR", + "CA", + "CH", + "DE", + "DK", + "ES", + "FI", + "FR", + "GB", + "IE", + "IT", + "JP", + "NL", + "NO", + "NZ", + "PT", + "SE", + "SG", + "US" +]); + +const BLOCKED_WATCHLIST_STATUSES = new Set(["match", "blocked", "review-required"]); + +function normalizeCountry(country) { + return String(country || "").trim().toUpperCase(); +} + +function stableJson(value) { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(",")}]`; + } + + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) + .join(",")}}`; + } + + return JSON.stringify(value); +} + +function auditDigest(value) { + return crypto.createHash("sha256").update(stableJson(value)).digest("hex"); +} + +function action(code, severity, message, owner, dueInDays) { + return { + code, + severity, + owner, + dueInDays, + message + }; +} + +function evaluateContributor(contributor, supportedCountries = DEFAULT_SUPPORTED_COUNTRIES) { + const country = normalizeCountry(contributor.country); + const actions = []; + + if (!contributor.identityAttested) { + actions.push( + action( + "identity_attestation_required", + "blocker", + "Collect contributor identity attestation before payout review.", + contributor.id, + 2 + ) + ); + } + + if (!supportedCountries.has(country)) { + actions.push( + action( + "unsupported_payout_country", + "blocker", + `Country ${country || "UNKNOWN"} is not currently enabled for direct payout routing.`, + contributor.id, + 1 + ) + ); + } + + if (!contributor.payoutMethodVerified) { + actions.push( + action( + "payout_method_required", + "blocker", + "Verify payout method before settlement can be released.", + contributor.id, + 3 + ) + ); + } + + if (contributor.taxFormStatus !== "valid") { + actions.push( + action( + "tax_form_required", + "blocker", + "Collect a valid tax form or platform-approved tax attestation.", + contributor.id, + 5 + ) + ); + } + + if (contributor.institutionRouting?.required && !contributor.institutionRouting.approved) { + actions.push( + action( + "institution_routing_approval_required", + "hold", + `Confirm payout routing with ${contributor.institutionRouting.institution || "the contributor institution"}.`, + contributor.id, + 5 + ) + ); + } + + if (BLOCKED_WATCHLIST_STATUSES.has(String(contributor.watchlistStatus || "").toLowerCase())) { + actions.push( + action( + "watchlist_review_required", + "blocker", + "Hold settlement until compliance review clears the contributor.", + contributor.id, + 1 + ) + ); + } + + if (!contributor.ipAssignmentConsent) { + actions.push( + action( + "ip_release_consent_required", + "hold", + "Confirm IP handoff consent before releasing sponsor license rights.", + contributor.id, + 4 + ) + ); + } + + const blockers = actions.filter((item) => item.severity === "blocker"); + const holds = actions.filter((item) => item.severity === "hold"); + const status = blockers.length > 0 ? "blocked" : holds.length > 0 ? "hold" : "ready"; + + return { + contributorId: contributor.id, + displayName: contributor.displayName, + country, + status, + blockerCount: blockers.length, + holdCount: holds.length, + actions + }; +} + +function validateAwardSplits(team) { + const contributorIds = new Set(team.contributors.map((contributor) => contributor.id)); + const splitTotal = team.awardSplits.reduce((sum, split) => sum + Number(split.percent || 0), 0); + const unknownSplits = team.awardSplits.filter((split) => !contributorIds.has(split.contributorId)); + const missingSplits = team.contributors.filter( + (contributor) => !team.awardSplits.some((split) => split.contributorId === contributor.id) + ); + const actions = []; + + if (Math.round(splitTotal * 100) / 100 !== 100) { + actions.push( + action( + "award_split_total_invalid", + "blocker", + `Award splits must total 100 percent; current total is ${splitTotal}.`, + team.id, + 1 + ) + ); + } + + for (const split of unknownSplits) { + actions.push( + action( + "award_split_unknown_contributor", + "blocker", + `Award split references unknown contributor ${split.contributorId}.`, + team.id, + 1 + ) + ); + } + + for (const contributor of missingSplits) { + actions.push( + action( + "award_split_missing_contributor", + "hold", + `Contributor ${contributor.id} has no payout split.`, + team.id, + 2 + ) + ); + } + + return { + splitTotal, + actions + }; +} + +function buildPayoutReadinessPacket(input, options = {}) { + const supportedCountries = options.supportedCountries + ? new Set([...options.supportedCountries].map(normalizeCountry)) + : DEFAULT_SUPPORTED_COUNTRIES; + const challenge = input.challenge; + const team = input.team; + const contributorResults = team.contributors.map((contributor) => + evaluateContributor(contributor, supportedCountries) + ); + const splitCheck = validateAwardSplits(team); + const contributorActions = contributorResults.flatMap((result) => result.actions); + const allActions = [...splitCheck.actions, ...contributorActions]; + const blockerCount = allActions.filter((item) => item.severity === "blocker").length; + const holdCount = allActions.filter((item) => item.severity === "hold").length; + const challengeActions = []; + + if (!challenge.settlementFunded) { + challengeActions.push( + action( + "settlement_not_funded", + "blocker", + "Sponsor funds must be confirmed before payout release.", + challenge.id, + 1 + ) + ); + } + + const finalActions = [...challengeActions, ...allActions]; + const finalBlockers = finalActions.filter((item) => item.severity === "blocker").length; + const finalHolds = finalActions.filter((item) => item.severity === "hold").length; + const status = finalBlockers > 0 ? "blocked" : finalHolds > 0 ? "hold" : "ready"; + const ipRelease = status === "ready" && challenge.settlementFunded ? "release-after-payout" : "blocked-until-ready"; + const payoutPlan = team.awardSplits.map((split) => { + const contributor = team.contributors.find((item) => item.id === split.contributorId); + return { + contributorId: split.contributorId, + displayName: contributor?.displayName || split.contributorId, + percent: split.percent, + amountUsd: Math.round(challenge.bountyAmountUsd * (Number(split.percent) / 100) * 100) / 100, + routingStatus: + contributorResults.find((result) => result.contributorId === split.contributorId)?.status || "unknown" + }; + }); + const unsignedPacket = { + challengeId: challenge.id, + challengeTitle: challenge.title, + sponsor: challenge.sponsor, + teamId: team.id, + teamName: team.name, + bountyAmountUsd: challenge.bountyAmountUsd, + settlementFunded: challenge.settlementFunded, + status, + blockerCount: finalBlockers, + holdCount: finalHolds, + ipRelease, + contributorResults, + payoutPlan, + remediationActions: finalActions.sort((left, right) => { + const severityRank = { blocker: 0, hold: 1, info: 2 }; + return severityRank[left.severity] - severityRank[right.severity] || left.dueInDays - right.dueInDays; + }) + }; + + return { + ...unsignedPacket, + auditDigest: auditDigest(unsignedPacket) + }; +} + +function summarizeForSponsor(packet) { + return { + status: packet.status, + settlementRecommendation: + packet.status === "ready" + ? "release_payout" + : packet.status === "hold" + ? "hold_for_routing_confirmation" + : "block_until_remediated", + nextActions: packet.remediationActions.map((item) => ({ + code: item.code, + owner: item.owner, + dueInDays: item.dueInDays, + message: item.message + })), + auditDigest: packet.auditDigest + }; +} + +module.exports = { + DEFAULT_SUPPORTED_COUNTRIES, + auditDigest, + buildPayoutReadinessPacket, + evaluateContributor, + normalizeCountry, + stableJson, + summarizeForSponsor, + validateAwardSplits +}; diff --git a/bounty-payout-eligibility-gate/test.js b/bounty-payout-eligibility-gate/test.js new file mode 100644 index 0000000..b1ed918 --- /dev/null +++ b/bounty-payout-eligibility-gate/test.js @@ -0,0 +1,92 @@ +const assert = require("assert"); +const sample = require("./sample-data.json"); +const { + auditDigest, + buildPayoutReadinessPacket, + evaluateContributor, + normalizeCountry, + stableJson, + summarizeForSponsor, + validateAwardSplits +} = require("./src/eligibilityGate"); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function testContributorReadiness() { + const ready = evaluateContributor(sample.team.contributors[0]); + assert.equal(ready.status, "ready"); + assert.equal(ready.actions.length, 0); + + const blocked = evaluateContributor({ + id: "solver-x", + displayName: "Solver X", + country: "zz", + identityAttested: false, + payoutMethodVerified: false, + taxFormStatus: "missing", + institutionRouting: { required: true, approved: false }, + watchlistStatus: "review-required", + ipAssignmentConsent: false + }); + + assert.equal(blocked.status, "blocked"); + assert(blocked.actions.some((item) => item.code === "unsupported_payout_country")); + assert(blocked.actions.some((item) => item.code === "watchlist_review_required")); + assert(blocked.actions.some((item) => item.code === "ip_release_consent_required")); +} + +function testAwardSplitValidation() { + const splitCheck = validateAwardSplits(sample.team); + assert.equal(splitCheck.splitTotal, 100); + assert.equal(splitCheck.actions.length, 0); + + const broken = clone(sample.team); + broken.awardSplits[0].percent = 60; + const brokenCheck = validateAwardSplits(broken); + assert(brokenCheck.actions.some((item) => item.code === "award_split_total_invalid")); +} + +function testPacketBlocksUntilPayoutEvidenceIsReady() { + const packet = buildPayoutReadinessPacket(sample); + assert.equal(packet.status, "blocked"); + assert.equal(packet.ipRelease, "blocked-until-ready"); + assert(packet.auditDigest.length === 64); + assert(packet.payoutPlan.some((entry) => entry.contributorId === "solver-2" && entry.amountUsd === 300)); + assert(packet.remediationActions[0].severity === "blocker"); + + const sponsorSummary = summarizeForSponsor(packet); + assert.equal(sponsorSummary.settlementRecommendation, "block_until_remediated"); + assert(sponsorSummary.nextActions.some((item) => item.code === "payout_method_required")); +} + +function testReadyPacketReleasesOnlyWhenFunded() { + const ready = clone(sample); + ready.team.contributors[1].payoutMethodVerified = true; + ready.team.contributors[1].taxFormStatus = "valid"; + ready.team.contributors[1].institutionRouting.approved = true; + const readyPacket = buildPayoutReadinessPacket(ready); + + assert.equal(readyPacket.status, "ready"); + assert.equal(readyPacket.ipRelease, "release-after-payout"); + + ready.challenge.settlementFunded = false; + const unfundedPacket = buildPayoutReadinessPacket(ready); + assert.equal(unfundedPacket.status, "blocked"); + assert(unfundedPacket.remediationActions.some((item) => item.code === "settlement_not_funded")); +} + +function testStableDigest() { + assert.equal(normalizeCountry(" us "), "US"); + assert.equal(stableJson({ b: 2, a: 1 }), stableJson({ a: 1, b: 2 })); + assert.equal(auditDigest({ b: 2, a: 1 }), auditDigest({ a: 1, b: 2 })); +} + +testContributorReadiness(); +testAwardSplitValidation(); +testPacketBlocksUntilPayoutEvidenceIsReady(); +testReadyPacketReleasesOnlyWhenFunded(); +testStableDigest(); + +console.log("bounty-payout-eligibility-gate tests passed");