From ea7129586008ac27c75fce4671f489b1cf6b1be0 Mon Sep 17 00:00:00 2001 From: aybvip Date: Sun, 10 May 2026 17:09:10 +0100 Subject: [PATCH 1/3] PiRC3: Pi Decentralized Commerce & Trust Protocol (PiDCTP) - Complete Spec & Soroban Implementation --- Cargo.toml | 14 ++++ PiRC3/1-vision.md | 44 ++++++++++ PiRC3/10-integration-pirc2.md | 32 +++++++ PiRC3/11-security-model.md | 49 +++++++++++ PiRC3/12-implementation-guide.md | 71 ++++++++++++++++ PiRC3/13-advanced-innovations.md | 54 ++++++++++++ PiRC3/2-core-design.md | 58 +++++++++++++ PiRC3/3-escrow-system.md | 76 +++++++++++++++++ PiRC3/4-reputation-engine.md | 94 +++++++++++++++++++++ PiRC3/5-dispute-resolution.md | 95 +++++++++++++++++++++ PiRC3/6-merchant-verification.md | 40 +++++++++ PiRC3/7-loyalty-rewards.md | 46 ++++++++++ PiRC3/8-data-types.md | 86 +++++++++++++++++++ PiRC3/9-error-codes.md | 59 +++++++++++++ PiRC3/ReadMe.md | 47 +++++++++++ ReadMe.md | 3 +- contracts/coordinator/Cargo.toml | 10 +++ contracts/coordinator/src/lib.rs | 32 +++++++ contracts/dispute/Cargo.toml | 10 +++ contracts/dispute/src/lib.rs | 110 ++++++++++++++++++++++++ contracts/escrow/Cargo.toml | 10 +++ contracts/escrow/src/lib.rs | 128 ++++++++++++++++++++++++++++ contracts/loyalty/Cargo.toml | 10 +++ contracts/loyalty/src/lib.rs | 40 +++++++++ contracts/merchant/Cargo.toml | 10 +++ contracts/merchant/src/lib.rs | 39 +++++++++ contracts/reputation/Cargo.toml | 10 +++ contracts/reputation/src/lib.rs | 140 +++++++++++++++++++++++++++++++ contracts/shared/Cargo.toml | 10 +++ contracts/shared/src/lib.rs | 124 +++++++++++++++++++++++++++ 30 files changed, 1550 insertions(+), 1 deletion(-) create mode 100644 Cargo.toml create mode 100644 PiRC3/1-vision.md create mode 100644 PiRC3/10-integration-pirc2.md create mode 100644 PiRC3/11-security-model.md create mode 100644 PiRC3/12-implementation-guide.md create mode 100644 PiRC3/13-advanced-innovations.md create mode 100644 PiRC3/2-core-design.md create mode 100644 PiRC3/3-escrow-system.md create mode 100644 PiRC3/4-reputation-engine.md create mode 100644 PiRC3/5-dispute-resolution.md create mode 100644 PiRC3/6-merchant-verification.md create mode 100644 PiRC3/7-loyalty-rewards.md create mode 100644 PiRC3/8-data-types.md create mode 100644 PiRC3/9-error-codes.md create mode 100644 PiRC3/ReadMe.md create mode 100644 contracts/coordinator/Cargo.toml create mode 100644 contracts/coordinator/src/lib.rs create mode 100644 contracts/dispute/Cargo.toml create mode 100644 contracts/dispute/src/lib.rs create mode 100644 contracts/escrow/Cargo.toml create mode 100644 contracts/escrow/src/lib.rs create mode 100644 contracts/loyalty/Cargo.toml create mode 100644 contracts/loyalty/src/lib.rs create mode 100644 contracts/merchant/Cargo.toml create mode 100644 contracts/merchant/src/lib.rs create mode 100644 contracts/reputation/Cargo.toml create mode 100644 contracts/reputation/src/lib.rs create mode 100644 contracts/shared/Cargo.toml create mode 100644 contracts/shared/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..0eb5ae12d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] +members = [ + "contracts/shared", + "contracts/escrow", + "contracts/reputation", + "contracts/dispute", + "contracts/merchant", + "contracts/loyalty", + "contracts/coordinator", +] +resolver = "2" + +[workspace.dependencies] +soroban-sdk = "22.0.0" diff --git a/PiRC3/1-vision.md b/PiRC3/1-vision.md new file mode 100644 index 000000000..d2db0255a --- /dev/null +++ b/PiRC3/1-vision.md @@ -0,0 +1,44 @@ +# PiRC3 Section 1: Vision & Problem Statement + +## The Commerce Trust Gap + +Pi Network has 60M+ Pioneers, but commerce between them remains limited by a fundamental problem: **lack of trust infrastructure**. When a Pioneer wants to buy goods or services from another Pioneer, there is no mechanism to: + +- Ensure the seller delivers what was promised +- Protect the buyer's payment if the seller fails +- Verify that either party has a reliable transaction history +- Resolve disputes fairly without centralized arbitration + +This is the **Commerce Trust Gap** — the missing layer between Pi's token economy (PiRC1) and its payment infrastructure (PiRC2). + +## Why Existing Solutions Fall Short + +| Approach | Limitation | +|----------|-----------| +| Social trust | Doesn't scale; easily manipulated | +| Centralized escrow | Single point of failure; not decentralized | +| Off-chain reputation | Not verifiable; cannot be used in smart contracts | +| Traditional courts | Too slow; too expensive for micro-transactions | + +## PiDCTP Vision + +**Pi Decentralized Commerce & Trust Protocol (PiDCTP)** provides a complete, on-chain trust infrastructure that enables safe commerce between any two Pioneers — even those who have never interacted before. + +### Core Principles + +1. **Trustless by Design**: No single entity controls funds, reputation, or dispute outcomes +2. **Progressive Trust**: Reputation builds over time through verified actions, not purchased +3. **Economic Alignment**: Incentives reward honest behavior; penalties deter fraud +4. **Modular Architecture**: Each module can be upgraded independently +5. **Pi-Native**: Built on Soroban/Stellar, integrated with PiRC1 and PiRC2 + +### Impact on the Pi Ecosystem + +- **For Buyers**: Escrow protection ensures you never lose funds to non-delivery +- **For Sellers**: Verified reputation attracts more customers; escrow confirms buyer has funds +- **For Merchants**: KYB verification establishes legitimacy and builds customer confidence +- **For the Community**: Dispute resolution provides fair outcomes; loyalty rewards honest commerce + +## Design Philosophy + +PiDCTP follows the principle of **minimum viable centralization** — using on-chain logic and economic incentives wherever possible, and decentralized human judgment (jurors) only when algorithmic resolution is insufficient. diff --git a/PiRC3/10-integration-pirc2.md b/PiRC3/10-integration-pirc2.md new file mode 100644 index 000000000..19eb2ad7c --- /dev/null +++ b/PiRC3/10-integration-pirc2.md @@ -0,0 +1,32 @@ +# PiRC3 Section 10: PiRC2 Integration Guide + +## Overview + +PiDCTP integrates with the PiRC2 Subscription Contract API to enable recurring commerce with escrow protection. + +## Integration Points + +### Subscription → Escrow +When a subscription charge occurs, PiDCTP can automatically create an escrow for the payment period, protecting both the subscriber and the service provider. + +### Subscription → Reputation +Subscription payment history contributes to reputation scores, providing verifiable transaction history for recurring commerce. + +### Subscription → Dispute +Subscription-related disputes (unauthorized charges, service not provided) are routed through the Dispute module with the `Subscription` juror specialty. + +## Data Flow + +``` +PiRC2 Subscription Charge + → PiDCTP Escrow (auto-create with subscription_id) + → PiDCTP Reputation (record completion) + → PiDCTP Loyalty (earn points) +``` + +## Contract Interface + +The `subscription_id` field in `EscrowAccount` links escrow transactions to their originating PiRC2 subscriptions, enabling: +- Automatic escrow creation on subscription renewal +- Bulk dispute resolution for subscription-related issues +- Subscription-specific reputation tracking diff --git a/PiRC3/11-security-model.md b/PiRC3/11-security-model.md new file mode 100644 index 000000000..01a7584ff --- /dev/null +++ b/PiRC3/11-security-model.md @@ -0,0 +1,49 @@ +# PiRC3 Section 11: Security Model + +## Threat Analysis + +| Threat | Mitigation | +|--------|-----------| +| Fund theft | Checks-Effects-Interactions; no admin override | +| Front-running | Commit-reveal voting; hidden jurors | +| Sybil attacks | Social graph analysis; stake requirements; badge non-transferability | +| Juror collusion | Commit-reveal; weighted voting; consensus tracking | +| Reputation farming | Sybil scoring; attestation weight limits; decay mechanism | +| Governance takeover | Timelock; multi-sig; progressive decentralization | +| Flash loan attacks | No price oracles; no instant governance | +| Reentrancy | All state changes before external calls | + +## Security Features + +### Smart Contract Level +- **Checks-Effects-Interactions**: All state changes before external token transfers +- **No admin override**: Admins cannot move funds or change escrow outcomes +- **Emergency pause**: Available for critical vulnerabilities only +- **Timelock**: 48-hour delay on all contract upgrades + +### Protocol Level +- **3-of-5 admin multi-sig**: Required for upgrades and parameter changes +- **Fee ceiling**: Maximum 10% fee enforced at protocol level +- **Score bounds**: Reputation score clamped to 50-1000 range + +### v1.1: Defense-in-Depth + +| Attack | Layer 1 | Layer 2 | Layer 3 | +|--------|---------|---------|---------| +| Sybil farming | Sybil scoring | Attestation limits | Badge non-transferability | +| Reputation buying | Soulbound badges | Attestation expiry | Counterparty tracking | +| Juror collusion | Commit-reveal | Weighted voting | Consensus tracking | +| Vote copying | Commit-reveal | Hidden jurors | Vetting requirements | + +## Bug Bounty Program + +| Severity | Reward | Criteria | +|----------|--------|----------| +| Critical | 5,000 Pi | Fund loss, governance takeover | +| High | 2,000 Pi | State corruption, bypass | +| Medium | 500 Pi | Logic errors, DoS | +| Low | 100 Pi | Minor issues, gas optimization | + +## Responsible Disclosure + +Report vulnerabilities to security@pidctp.org. Do not publicly disclose until patch is deployed. diff --git a/PiRC3/12-implementation-guide.md b/PiRC3/12-implementation-guide.md new file mode 100644 index 000000000..7c083fa80 --- /dev/null +++ b/PiRC3/12-implementation-guide.md @@ -0,0 +1,71 @@ +# PiRC3 Section 12: Implementation Guide + +## Technical Stack + +- **Smart Contracts**: Rust + Soroban SDK (Stellar blockchain) +- **Token Standard**: Stellar Classic Asset (Pi) +- **Randomness**: Verifiable Random Function (VRF) for juror selection +- **Privacy (roadmap)**: Zero-Knowledge Proofs (ZK-SNARKs) + +## Project Structure + +``` +contracts/ +├── shared/ # Shared types & events +├── escrow/ # Escrow + Milestone + Group Escrow +├── reputation/ # Reputation + Badges + Attestations + Sybil + ZK +├── dispute/ # Dispute + Juror Vetting + Weighted Voting +├── merchant/ # Merchant verification +├── loyalty/ # Loyalty & rewards +└── coordinator/ # Entry point & router +``` + +## Build & Test + +```bash +# Build all contracts +soroban contract build + +# Run unit tests +cargo test + +# Run integration tests +cargo test --test full_flow_test +``` + +## Deployment + +### Testnet +```bash +./scripts/deploy_testnet.sh +``` + +### Mainnet +```bash +# Pre-deployment checklist +./scripts/verify_deployment.sh +./scripts/deploy_mainnet.sh +``` + +## Configuration + +| Parameter | Testnet | Mainnet | +|-----------|---------|---------| +| Fee | 1% | 1% | +| Evidence duration | 72h | 72h | +| Voting duration | 48h | 48h | +| Reveal duration | 24h | 24h | +| Appeal window | 24h | 24h | +| Dispute fee | 1 Pi | 1 Pi | +| Appeal fee | 2 Pi | 2 Pi | +| Juror count | 3 | 5 | +| Admin threshold | 2-of-3 | 3-of-5 | + +## Upgrade Process + +1. Deploy new contract version +2. Submit upgrade transaction with 48h timelock +3. Multi-sig approval (3-of-5 on mainnet) +4. Wait for timelock expiry +5. Execute upgrade +6. Verify deployment with `verify_deployment.sh` diff --git a/PiRC3/13-advanced-innovations.md b/PiRC3/13-advanced-innovations.md new file mode 100644 index 000000000..c91ce96ed --- /dev/null +++ b/PiRC3/13-advanced-innovations.md @@ -0,0 +1,54 @@ +# PiRC3 Section 13: Advanced Innovations (v1.1) + +## Overview + +7 advanced innovations researched from Kleros, Aragon, Status Network, and academic literature on decentralized justice and Sybil resistance. + +| Innovation | Module | Attack Vector | +|-----------|--------|--------------| +| Soulbound Badges | Reputation | Reputation buying/selling | +| Milestone Escrow | Escrow | All-or-nothing risk | +| Group Escrow | Escrow | Multi-party coordination | +| Sybil Resistance | Reputation | Fake account farming | +| Juror Vetting | Dispute | Unqualified juror selection | +| Reputation Attestations | Reputation | Cold-start problem | +| ZK Verification | Reputation | Privacy leakage | + +## Soulbound Reputation Badges + +Non-transferable credential tokens representing what a Pioneer has DONE, not what they HOLD. 10 badge types with +2 score bonus each (max +20). Revocable only for proven fraud (-10 penalty). + +## Milestone Escrow + +Multi-stage fund release for large/complex orders. Each milestone has independent amount, deadline, and confirmation. Failed milestones refund remaining funds to buyer. + +## Group Escrow + +Multi-party escrow for group purchases. Each participant contributes independently with proportional refund shares. All must fund before escrow activates. + +## Social Graph Sybil Resistance + +On-chain transaction pattern analysis: tracks unique counterparties, flags accounts with <30% unique counterparty ratio. Sybil score 0-10000. High Sybil score reduces effective reputation. + +## Juror Vetting & Reputation-Weighted Voting + +Jurors must meet minimum Silver reputation + 10 Pi stake. Specialty matching ensures relevant expertise. Voting weighted by reputation tier (Bronze=1 to Diamond=5) with consensus bonus. + +## Reputation Attestations + +Third-party vouching with tier-derived weights (1-5). 180-day expiry. Minimum Silver to attest. Reaching 20 attestation score grants +5 reputation bonus. + +## ZK Reputation Verification Roadmap + +Phase 1 (current): Simple on-chain tier verification. Phase 2 (planned): Off-chain ZK proof generation. Phase 3 (future): Full ZK-SNARK integration with dedicated verifier contract. + +## Cross-Innovation Defense-in-Depth + +| Attack | Layer 1 | Layer 2 | Layer 3 | +|--------|---------|---------|---------| +| Sybil farming | Sybil scoring | Attestation limits | Badge non-transferability | +| Reputation buying | Soulbound badges | Attestation expiry | Counterparty tracking | +| Juror collusion | Commit-reveal | Weighted voting | Consensus tracking | +| Vote copying | Commit-reveal | Hidden jurors | Vetting requirements | +| Cold-start spam | Min Silver to attest | Attestation expiry | Stake requirements | +| Score privacy leak | ZK tier verification | Off-chain proof gen | Dedicated verifier | diff --git a/PiRC3/2-core-design.md b/PiRC3/2-core-design.md new file mode 100644 index 000000000..bc33a399c --- /dev/null +++ b/PiRC3/2-core-design.md @@ -0,0 +1,58 @@ +# PiRC3 Section 2: Core Design & Architecture + +## 5-Module Architecture + +``` + ┌───────────────────┐ + │ Coordinator │ ← Entry point & router + └─────────┬─────────┘ + │ + ┌─────────┬──────────┼──────────┬─────────┐ + ▼ ▼ ▼ ▼ ▼ + ┌─────────┐┌─────────┐┌─────────┐┌─────────┐┌─────────┐ + │ Escrow ││Reputation││ Dispute ││Merchant ││ Loyalty │ + │ Module ││ Engine ││Protocol ││ Verify ││ Rewards │ + └─────────┘└─────────┘└─────────┘└─────────┘└─────────┘ +``` + +## Module Interactions + +### Transaction Flow (Happy Path) +1. Buyer creates escrow via Coordinator +2. Buyer funds escrow with Pi tokens +3. Seller confirms delivery +4. Buyer confirms receipt → funds released to seller +5. Reputation scores updated for both parties +6. Loyalty points earned by both parties + +### Dispute Flow +1. Either party opens dispute via Coordinator +2. Escrow funds frozen +3. Evidence submitted by both parties +4. Jurors selected and vote (commit-reveal) +5. Ruling executed → funds distributed per ruling +6. Reputation scores updated based on ruling + +## Coordinator Contract + +The Coordinator is the single entry point for all PiDCTP interactions. It: +- Routes calls to the appropriate module +- Enforces cross-module invariants +- Manages protocol-level configuration +- Handles emergency pause + +## Fee Structure + +| Fee Type | Amount | Recipient | +|----------|--------|-----------| +| Escrow fee | 1% of transaction | Treasury | +| Dispute filing fee | 1 Pi | Juror pool | +| Appeal fee | 2 Pi | Juror pool | +| Merchant verification | 2-10 Pi | Treasury | + +## Upgrade Strategy + +- Each module is independently upgradeable +- 48-hour timelock on all contract upgrades +- 3-of-5 admin multi-sig required for upgrades +- Emergency pause available for critical vulnerabilities diff --git a/PiRC3/3-escrow-system.md b/PiRC3/3-escrow-system.md new file mode 100644 index 000000000..4a11d37af --- /dev/null +++ b/PiRC3/3-escrow-system.md @@ -0,0 +1,76 @@ +# PiRC3 Section 3: Escrow Payment System + +## Overview + +The Escrow module provides trustless payment protection for commerce transactions between Pioneers. + +## Escrow Lifecycle + +``` +Created → Funded → Delivered → Completed + ↘ Disputed → Resolved + ↘ Cancelled ↘ Expired +``` + +## Key Parameters + +| Parameter | Value | Description | +|-----------|-------|-------------| +| Fee | 1% (100 bps) | Deducted from seller payout | +| Min auto-release timeout | 24 hours | Buyer must confirm within this window | +| Max fee | 10% | Protocol-enforced ceiling | + +## Escrow States + +| State | Description | +|-------|-------------| +| Created | Escrow created, awaiting buyer funding | +| Funded | Buyer deposited payment | +| Delivered | Seller confirmed delivery | +| Completed | Buyer confirmed receipt, funds released | +| Disputed | Dispute opened, funds frozen | +| Resolved | Dispute ruling executed | +| Expired | Seller failed to deliver by deadline | +| Cancelled | Cancelled by buyer or mutual agreement | +| MilestoneActive | v1.1: Milestone escrow in progress | + +## v1.1: Milestone Escrow + +Multi-stage fund release for large or complex orders: +- Each milestone has its own amount, deadline, and independent confirmation +- If a milestone fails, remaining funds are refunded to buyer +- Minimum 2 milestones required + +## v1.1: Group Escrow + +Multi-party escrow for group purchases: +- Multiple buyers pool funds for a single purchase +- Each participant has proportional refund share +- All participants must fund before escrow becomes active + +## Contract Interface + +```rust +fn create_escrow(env, buyer, seller, amount, token, delivery_deadline, auto_release_timeout, order_metadata) -> u64 +fn fund_escrow(env, buyer, escrow_id) +fn confirm_delivery(env, seller, escrow_id) +fn confirm_receipt(env, buyer, escrow_id) +fn auto_release(env, caller, escrow_id) +fn cancel_escrow(env, caller, escrow_id) +fn expire_escrow(env, caller, escrow_id) +fn freeze_for_dispute(env, caller, escrow_id) +fn execute_ruling(env, caller, escrow_id, buyer_percentage) +// v1.1 +fn create_milestone_escrow(env, buyer, seller, total_amount, token, milestone_amounts, milestone_deadlines, milestone_descriptions, auto_release_timeout, order_metadata) -> u64 +fn submit_milestone(env, seller, escrow_id, milestone_id) +fn confirm_milestone(env, buyer, escrow_id, milestone_id) +fn create_group_escrow(env, organizer, seller, token, total_amount, participants, funding_deadline, delivery_deadline, auto_release_timeout, order_metadata) -> u64 +fn fund_group_escrow(env, buyer, escrow_id) +``` + +## Security Considerations + +- Checks-Effects-Interactions pattern enforced +- Funds held in contract address, not admin-controlled +- Only four release paths: confirm_receipt, auto_release, execute_ruling, cancel_escrow +- No admin override for escrow state diff --git a/PiRC3/4-reputation-engine.md b/PiRC3/4-reputation-engine.md new file mode 100644 index 000000000..672c977e8 --- /dev/null +++ b/PiRC3/4-reputation-engine.md @@ -0,0 +1,94 @@ +# PiRC3 Section 4: Reputation Engine + +## Overview + +The Reputation Engine provides verifiable, portable trust scores based on on-chain transaction history. + +## Score System + +| Tier | Score Range | Starting Score | +|------|-------------|---------------| +| Bronze | 50-199 | — | +| Silver | 200-449 | 200 (new profiles) | +| Gold | 450-699 | — | +| Platinum | 700-899 | — | +| Diamond | 900-1000 | — | + +## Score Changes + +| Event | Buyer | Seller | +|-------|-------|--------| +| Escrow completed | +3 | +5 | +| Escrow expired | — | -15 | +| Dispute ruling in favor | +10 | +10 | +| Dispute ruling against | -20 | -20 | +| Merchant verification | — | +50 | +| Badge awarded (v1.1) | +2 | +2 | + +## v1.1: Soulbound Badges + +Non-transferable credential tokens representing specific achievements: + +| Badge | Criteria | +|-------|----------| +| FirstTrade | Completed first escrow | +| TrustedBuyer | 10+ completed purchases | +| TrustedSeller | 10+ completed sales | +| VerifiedMerchant | Passed KYB verification | +| JurorVeteran | Served on 5+ dispute panels | +| CommunityGuardian | 20+ rulings with consensus | +| EarlyAdopter | Active in first 90 days | +| PlatinumTrader | 100+ completed escrows | +| DiamondElite | Reached Diamond tier | +| LoyaltyChampion | Reached Legendary loyalty | + +## v1.1: Sybil Resistance + +On-chain transaction pattern analysis to detect fake accounts: +- Tracks unique counterparties per Pioneer +- Flags accounts with <30% unique counterparty ratio +- Sybil score: 0 (human) to 10000 (definite Sybil) +- High Sybil score reduces effective reputation score + +## v1.1: Reputation Attestations + +Third-party vouching system: +- Verified Pioneers can attest to another's trustworthiness +- Weight derived from attester's tier (Bronze=1 to Diamond=5) +- Attestations expire after 180 days +- Minimum Silver tier required to attest + +## v1.1: ZK Verification Roadmap + +Future: prove reputation tier without revealing exact score using ZK-SNARKs. + +## Decay Mechanism + +- 1% score decay per week of inactivity (after 30 days) +- Minimum score: 50 (cannot decay below) +- Any transaction activity resets the decay timer + +## Contract Interface + +```rust +fn create_profile(env, pioneer) -> ReputationProfile +fn record_escrow_completion(env, caller, pioneer, as_seller) -> u32 +fn record_escrow_expiry(env, caller, seller) -> u32 +fn record_dispute_ruling(env, caller, pioneer, ruling_in_favor, as_seller) -> u32 +fn set_merchant_status(env, caller, pioneer, is_verified) +fn apply_decay(env, pioneer) -> u32 +fn get_profile(env, pioneer) -> ReputationProfile +fn get_score(env, pioneer) -> u32 +fn get_tier(env, pioneer) -> ReputationTier +fn verify_threshold(env, pioneer, minimum_score) -> bool +// v1.1 +fn award_badge(env, caller, pioneer, badge, reason) +fn revoke_badge(env, caller, pioneer, badge) +fn has_badge(env, pioneer, badge) -> bool +fn create_attestation(env, attester, attested, attestation_type) -> u64 +fn revoke_attestation(env, caller, attestation_id) +fn update_sybil_profile(env, caller, pioneer, new_counterparty) +fn get_sybil_profile(env, pioneer) -> SybilProfile +fn get_effective_score(env, pioneer) -> u32 +fn verify_tier_claim(env, pioneer, claimed_tier) -> bool +``` diff --git a/PiRC3/5-dispute-resolution.md b/PiRC3/5-dispute-resolution.md new file mode 100644 index 000000000..358e9ec1a --- /dev/null +++ b/PiRC3/5-dispute-resolution.md @@ -0,0 +1,95 @@ +# PiRC3 Section 5: Dispute Resolution Protocol + +## Overview + +Decentralized arbitration system for resolving commerce disputes between Pioneers. + +## Dispute Categories + +| Category | Description | +|----------|-------------| +| NonDelivery | Seller didn't deliver | +| NotAsDescribed | Item doesn't match description | +| DamagedDefective | Item arrived damaged | +| DeliveryDispute | Delivery timing or method issue | +| ServiceNotProvided | Service wasn't performed | +| UnauthorizedCharge | Unauthorized deduction | +| Other | Miscellaneous | + +## Dispute Phases + +``` +Filed → Evidence → Voting → Ruling → Final + ↘ Appealed → Final +``` + +## Timelines + +| Phase | Duration | +|-------|----------| +| Evidence submission | 72 hours | +| Commit voting | 48 hours | +| Reveal votes | 24 hours | +| Appeal window | 24 hours | + +## Commit-Reveal Voting + +Jurors vote in two phases to prevent vote copying: +1. **Commit**: Juror submits hash(vote + salt) +2. **Reveal**: After voting deadline, juror reveals vote and salt +3. **Verification**: Contract verifies hash matches commitment + +## v1.1: Juror Vetting + +Jurors must meet minimum requirements: +- Minimum Silver reputation (200+ score) +- Minimum 10 Pi stake as juror bond +- Specialty declaration: General, Commerce, DigitalGoods, Services, Subscription + +Specialty matching ensures relevant expertise for each dispute category. + +## v1.1: Reputation-Weighted Voting + +Instead of 1-juror-1-vote, votes are weighted by reputation: + +| Tier | Base Weight | Consensus Bonus | +|------|-------------|-----------------| +| Bronze | 1 | +1 if >80% consensus & 3+ cases | +| Silver | 2 | +1 if >80% consensus & 3+ cases | +| Gold | 3 | +1 if >80% consensus & 3+ cases | +| Platinum | 4 | +1 if >80% consensus & 3+ cases | +| Diamond | 5 | +1 if >80% consensus & 3+ cases | + +## Ruling Types + +| Ruling | Buyer Refund | +|--------|-------------| +| FullRefund | 100% | +| PartialRefund | 50% | +| SellerFavored | 0% | +| Split | 50% | +| Dismissed | 0% | + +## Anti-Collusion Measures + +- Hidden juror identities until after ruling +- Commit-reveal prevents vote copying +- Juror bond (10 Pi) slashable for non-participation +- Penalty points for non-reveal (4+ = ineligible) + +## Contract Interface + +```rust +fn open_dispute(env, caller, escrow_id, filer, respondent, category, initial_evidence, jurors) -> u64 +fn submit_evidence(env, party, dispute_id, evidence_hash) +fn start_voting(env, caller, dispute_id) +fn commit_vote(env, juror, dispute_id, commitment) +fn reveal_vote(env, juror, dispute_id, vote, salt) +fn execute_ruling(env, caller, dispute_id) -> (DisputeRuling, u32) +// v1.1 +fn register_juror(env, juror, specialty, reputation_score, stake) +fn deactivate_juror(env, juror) +fn get_juror_profile(env, juror) -> JurorVettingProfile +fn is_juror_eligible(env, juror, category) -> bool +fn execute_weighted_ruling(env, caller, dispute_id) -> (DisputeRuling, u32, u32) +``` diff --git a/PiRC3/6-merchant-verification.md b/PiRC3/6-merchant-verification.md new file mode 100644 index 000000000..09f359a21 --- /dev/null +++ b/PiRC3/6-merchant-verification.md @@ -0,0 +1,40 @@ +# PiRC3 Section 6: Merchant Verification + +## Overview + +Lightweight Know-Your-Business (KYB) process establishing merchant legitimacy within the Pi ecosystem. + +## Verification Levels + +| Level | Requirements | Benefits | +|-------|-------------|----------| +| Basic | Business name, category, jurisdiction | Basic listing | +| Standard | + Location, volume proof | Enhanced visibility | +| Premium | + Full documentation, audit | Featured placement | + +## Verification Status + +| Status | Description | +|--------|-------------| +| NotApplied | Default state | +| Pending | Application submitted | +| UnderReview | Being evaluated | +| InfoRequested | Additional info needed | +| Approved | Verification granted | +| Suspended | Temporarily suspended | +| Revoked | Permanently removed | +| Expired | Annual renewal needed | + +## Merchant Categories + +DigitalGoods, PhysicalGoods, Services, FoodAndBeverage, Entertainment, Education, HealthAndWellness, ProfessionalServices, Retail, Other + +## Contract Interface + +```rust +fn apply_verification(env, merchant, business_name_hash, category, jurisdiction, metadata_uri) -> u64 +fn approve_verification(env, caller, merchant, level) +fn suspend_merchant(env, caller, merchant, reason_hash) +fn revoke_verification(env, caller, merchant, reason_hash) +fn get_merchant(env, merchant) -> MerchantProfile +``` diff --git a/PiRC3/7-loyalty-rewards.md b/PiRC3/7-loyalty-rewards.md new file mode 100644 index 000000000..ba86bcd5f --- /dev/null +++ b/PiRC3/7-loyalty-rewards.md @@ -0,0 +1,46 @@ +# PiRC3 Section 7: Loyalty & Rewards + +## Overview + +Ecosystem incentives rewarding consistent honest commerce behavior. + +## Loyalty Tiers + +| Tier | Points Required | +|------|----------------| +| Starter | 0 | +| Regular | 100 | +| Trusted | 500 | +| Elite | 2000 | +| Legendary | 10000 | + +## Points Earning + +| Action | Points | +|--------|--------| +| Complete escrow (buyer) | 10 | +| Complete escrow (seller) | 15 | +| Serve as juror | 20 | +| Consensus vote | 5 bonus | +| Referral (active Pioneer) | 50 | +| Activity streak (7 days) | 25 | + +## Reward Types + +| Reward | Description | +|--------|-------------| +| FeeWaiver | Reduced escrow fees | +| JurorPriority | Priority juror selection | +| MerchantSpotlight | Featured merchant listing | +| ReputationBoost | Temporary score boost | +| GovernanceVote | Protocol governance voting | + +## Contract Interface + +```rust +fn create_profile(env, pioneer) -> LoyaltyProfile +fn earn_points(env, caller, pioneer, action, amount) +fn redeem_reward(env, pioneer, reward_type, amount) +fn update_streak(env, pioneer) +fn get_profile(env, pioneer) -> LoyaltyProfile +``` diff --git a/PiRC3/8-data-types.md b/PiRC3/8-data-types.md new file mode 100644 index 000000000..f9f26ed10 --- /dev/null +++ b/PiRC3/8-data-types.md @@ -0,0 +1,86 @@ +# PiRC3 Section 8: Data Types Reference + +## Enums + +### EscrowState +Created, Funded, Delivered, Completed, Disputed, Resolved, Expired, Cancelled, MilestoneActive + +### DisputeCategory +NonDelivery, NotAsDescribed, DamagedDefective, DeliveryDispute, ServiceNotProvided, UnauthorizedCharge, Other + +### DisputeRuling +FullRefund, PartialRefund, SellerFavored, Split, Dismissed + +### DisputePhase +Filed, Evidence, Voting, Ruling, Appealed, Final + +### ReputationTier +Bronze, Silver, Gold, Platinum, Diamond + +### VerificationLevel +None, Basic, Standard, Premium + +### VerificationStatus +NotApplied, Pending, UnderReview, InfoRequested, Approved, Suspended, Revoked, Expired + +### MerchantCategory +DigitalGoods, PhysicalGoods, Services, FoodAndBeverage, Entertainment, Education, HealthAndWellness, ProfessionalServices, Retail, Other + +### LoyaltyTier +Starter, Regular, Trusted, Elite, Legendary + +### RewardType +FeeWaiver, JurorPriority, MerchantSpotlight, ReputationBoost, GovernanceVote + +### SoulboundBadge (v1.1) +FirstTrade, TrustedBuyer, TrustedSeller, VerifiedMerchant, JurorVeteran, CommunityGuardian, EarlyAdopter, PlatinumTrader, DiamondElite, LoyaltyChampion + +### AttestationType (v1.1) +IdentityVouch, CommerceVouch, SkillVouch, CommunityVouch + +### MilestoneState (v1.1) +Pending, Submitted, Confirmed, Disputed, Released, Expired + +### GroupEscrowState (v1.1) +Collecting, FullyFunded, Delivered, Completed, Disputed, Resolved, Cancelled, Expired + +### JurorSpecialty (v1.1) +General, Commerce, DigitalGoods, Services, Subscription + +## Structs + +### EscrowAccount +escrow_id, buyer, seller, amount, token, state, created_at, delivery_deadline, confirmation_deadline, auto_release_timeout, subscription_id, order_metadata, is_milestone, milestones, current_milestone, released_amount + +### ReputationProfile +pioneer, score, tier, total_escrows, completed_escrows, expired_escrows, disputes_as_buyer, disputes_as_seller, rulings_in_favor, rulings_against, is_verified_merchant, created_at, last_active, history_root, score_nonce, badge_count, attestation_score, sybil_score, unique_counterparties + +### DisputeCase +dispute_id, escrow_id, filer, respondent, category, phase, jurors, commit_votes, filer_evidence, respondent_evidence, filed_at, evidence_deadline, voting_deadline, reveal_deadline, ruling, is_appealed, appeal_fee, juror_count + +### Milestone (v1.1) +milestone_id, description_hash, amount, state, deadline, submitted_at, confirmed_at + +### GroupParticipant (v1.1) +buyer, amount, funded, funded_at, refund_percentage + +### BadgeOwnership (v1.1) +pioneer, badge, awarded_at, award_reason, revoked + +### Attestation (v1.1) +attestation_id, attester, attested, attestation_type, attester_reputation, weight, created_at, expires_at, active + +### SybilProfile (v1.1) +pioneer, unique_counterparties, total_transactions, reciprocal_ratio, avg_tx_interval, cluster_flag, last_analysis, sybil_score + +### JurorVettingProfile (v1.1) +juror, reputation_score, cases_served, cases_consensus, consensus_rate, specialty, active, stake, last_served, penalty_points + +### MerchantProfile +merchant, level, business_name_hash, category, status, jurisdiction, total_volume, total_orders, avg_rating, verified_at, expires_at, location_count, metadata_uri + +### LoyaltyProfile +pioneer, points, tier, lifetime_points, redeemable_points, last_activity, referral_code, referral_count, activity_streak + +### ModuleAddresses +escrow, reputation, dispute, merchant_verification, loyalty diff --git a/PiRC3/9-error-codes.md b/PiRC3/9-error-codes.md new file mode 100644 index 000000000..6db58e412 --- /dev/null +++ b/PiRC3/9-error-codes.md @@ -0,0 +1,59 @@ +# PiRC3 Section 9: Error Codes + +## Format: MMMEEE + +MMM = Module (ESC, REP, DIS, MER, LOY, COO) +EEE = Error number (001-999) + +## Escrow Errors (ESC) + +| Code | Message | Description | +|------|---------|-------------| +| ESC001 | Not buyer | Caller is not the buyer | +| ESC002 | Not seller | Caller is not the seller | +| ESC003 | Not Created | Escrow not in Created state | +| ESC004 | Not Funded | Escrow not in Funded state | +| ESC005 | Not Delivered | Escrow not in Delivered state | +| ESC006 | Not Disputed | Escrow not in Disputed state | +| ESC007 | Amount zero | Escrow amount cannot be zero | +| ESC008 | Deadline past | Deadline already passed | +| ESC009 | Buyer seller same | Buyer and seller cannot be same | +| ESC010 | Timeout min 1d | Auto-release timeout minimum 1 day | +| ESC011 | Invalid percentage | Buyer percentage out of range | +| ESC012 | Cannot cancel | Cannot cancel in current state | +| ESC013 | Timeout not reached | Auto-release timeout not yet reached | +| ESC014 | Not expired | Delivery deadline not yet passed | +| ESC015 | Fee exceed max | Fee cannot exceed 10% | +| ESC016 | Already initialized | Contract already initialized | +| ESC017 | Protocol paused | Protocol is currently paused | +| ESC018 | Only coordinator | Only coordinator can call | + +## Reputation Errors (REP) + +| Code | Message | Description | +|------|---------|-------------| +| REP001 | Profile exists | Profile already created | +| REP002 | Only coordinator | Only coordinator can call | +| REP003 | Protocol paused | Protocol is currently paused | +| REP004 | Min Silver to attest | Minimum Silver tier to create attestation | +| REP005 | Cannot self-attest | Cannot attest for yourself | +| REP006 | Already revoked | Badge/attestation already revoked | + +## Dispute Errors (DIS) + +| Code | Message | Description | +|------|---------|-------------| +| DIS001 | Not evidence phase | Not in evidence submission phase | +| DIS002 | Deadline passed | Submission deadline passed | +| DIS003 | Not a party | Caller is not a party to the dispute | +| DIS004 | Evidence limit | Maximum 5 evidence items per party | +| DIS005 | Not voting phase | Not in voting phase | +| DIS006 | Not a juror | Caller is not a selected juror | +| DIS007 | Already committed | Juror already committed a vote | +| DIS008 | Not in reveal phase | Not in reveal phase | +| DIS009 | Already revealed | Vote already revealed | +| DIS010 | Not ruling phase | Not in ruling phase | +| DIS011 | No votes revealed | No votes have been revealed | +| DIS012 | Min Silver reputation | Minimum Silver reputation for juror | +| DIS013 | Min 10 Pi stake | Minimum 10 Pi stake for juror | +| DIS014 | Already registered | Juror already registered | diff --git a/PiRC3/ReadMe.md b/PiRC3/ReadMe.md new file mode 100644 index 000000000..d16de4409 --- /dev/null +++ b/PiRC3/ReadMe.md @@ -0,0 +1,47 @@ +# PiRC3: Pi Decentralized Commerce & Trust Protocol (PiDCTP) + +## Overview + +PiRC3 introduces a **decentralized commerce and trust infrastructure** layer for the Pi Network ecosystem. While PiRC1 defines token economics and PiRC2 enables subscription payments, PiRC3 solves the critical **Commerce Trust Gap** — enabling Pioneers to transact safely with verifiable reputation and escrow protection. + +## Table of Contents + +1. [Vision & Problem Statement](1-vision.md) +2. [Core Design & Architecture](2-core-design.md) +3. [Escrow Payment System](3-escrow-system.md) +4. [Reputation Engine](4-reputation-engine.md) +5. [Dispute Resolution Protocol](5-dispute-resolution.md) +6. [Merchant Verification](6-merchant-verification.md) +7. [Loyalty & Rewards](7-loyalty-rewards.md) +8. [Data Types Reference](8-data-types.md) +9. [Error Codes Reference](9-error-codes.md) +10. [PiRC2 Integration Guide](10-integration-pirc2.md) +11. [Security Model](11-security-model.md) +12. [Implementation Guide](12-implementation-guide.md) +13. [Advanced Innovations (v1.1)](13-advanced-innovations.md) + +## Key Features + +| Module | Description | +|--------|-------------| +| **Escrow** | Multi-sig payment protection with milestone & group escrow | +| **Reputation** | Verifiable on-chain trust scores with Soulbound Badges | +| **Dispute** | Decentralized arbitration with vetted, weighted jurors | +| **Merchant** | 3-level KYB verification for business legitimacy | +| **Loyalty** | Economic incentives for honest commerce | + +## Relationship to PiRC1 & PiRC2 + +``` +┌─────────────────────────────────┐ +│ PiRC3: Commerce & Trust │ ← This proposal +├─────────────────────────────────┤ +│ PiRC2: Subscription API │ ← Recurring payments +├─────────────────────────────────┤ +│ PiRC1: Token Design │ ← Token economics +└─────────────────────────────────┘ +``` + +## Community Feedback + +Community feedback is an essential part of this process. Pioneers are encouraged to review these documents and provide feedback through GitHub Issues and Pull Requests. diff --git a/ReadMe.md b/ReadMe.md index 043f8a08c..62db4dfd8 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,2 +1,3 @@ - [PiRC1: Pi Ecosystem Token Design](./PiRC1/ReadMe.md) -- [PiRC2: Subscription Contract API](./PiRC2/ReadMe.md) \ No newline at end of file +- [PiRC2: Subscription Contract API](./PiRC2/ReadMe.md) +- [PiRC3: Pi Decentralized Commerce & Trust Protocol (PiDCTP)](./PiRC3/ReadMe.md) \ No newline at end of file diff --git a/contracts/coordinator/Cargo.toml b/contracts/coordinator/Cargo.toml new file mode 100644 index 000000000..8a8032ee3 --- /dev/null +++ b/contracts/coordinator/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "coordinator" +version = "1.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } diff --git a/contracts/coordinator/src/lib.rs b/contracts/coordinator/src/lib.rs new file mode 100644 index 000000000..1ad46bfec --- /dev/null +++ b/contracts/coordinator/src/lib.rs @@ -0,0 +1,32 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, BytesN, Map}; + +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub struct ModuleAddresses { pub escrow: Address, pub reputation: Address, pub dispute: Address, pub merchant_verification: Address, pub loyalty: Address } + +const MODULES: Symbol = Symbol::new(&[], "modules"); +const ADMIN: Symbol = Symbol::new(&[], "admin"); +const PAUSED: Symbol = Symbol::new(&[], "paused"); + +#[contract] pub struct CoordinatorContract; +#[contractimpl] +impl CoordinatorContract { + pub fn initialize(env: Env, admin: Address, modules: ModuleAddresses) { + admin.require_auth(); + env.storage().persistent().set(&ADMIN, &admin); + env.storage().persistent().set(&MODULES, &modules); + env.storage().persistent().set(&PAUSED, &false); + } + pub fn set_modules(env: Env, admin: Address, modules: ModuleAddresses) { + admin.require_auth(); + env.storage().persistent().set(&MODULES, &modules); + } + pub fn pause(env: Env, admin: Address) { + admin.require_auth(); env.storage().persistent().set(&PAUSED, &true); + } + pub fn unpause(env: Env, admin: Address) { + admin.require_auth(); env.storage().persistent().set(&PAUSED, &false); + } + pub fn is_paused(env: Env) -> bool { env.storage().persistent().get(&PAUSED).unwrap_or(false) } + pub fn get_modules(env: Env) -> ModuleAddresses { env.storage().persistent().get(&MODULES).unwrap() } +} diff --git a/contracts/dispute/Cargo.toml b/contracts/dispute/Cargo.toml new file mode 100644 index 000000000..4f68a1047 --- /dev/null +++ b/contracts/dispute/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "dispute" +version = "1.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } diff --git a/contracts/dispute/src/lib.rs b/contracts/dispute/src/lib.rs new file mode 100644 index 000000000..de40adf2c --- /dev/null +++ b/contracts/dispute/src/lib.rs @@ -0,0 +1,110 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, BytesN, Vec}; + +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub enum DisputeCategory { NonDelivery, NotAsDescribed, DamagedDefective, DeliveryDispute, ServiceNotProvided, UnauthorizedCharge, Other } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub enum DisputeRuling { FullRefund, PartialRefund, SellerFavored, Split, Dismissed } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub enum DisputePhase { Filed, Evidence, Voting, Ruling, Appealed, Final } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub enum ReputationTier { Bronze, Silver, Gold, Platinum, Diamond } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub enum JurorSpecialty { General, Commerce, DigitalGoods, Services, Subscription } + +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub struct JurorVote { pub juror: Address, pub vote: DisputeRuling, pub confidence: u8, pub voted_at: u64, pub justification_hash: BytesN<32> } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub struct JurorVettingProfile { pub juror: Address, pub reputation_score: u32, pub cases_served: u32, pub cases_consensus: u32, pub consensus_rate: u32, pub specialty: JurorSpecialty, pub active: bool, pub stake: i128, pub last_served: u64, pub penalty_points: u32 } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub struct DisputeCase { pub dispute_id: u64, pub escrow_id: u64, pub filer: Address, pub respondent: Address, pub category: DisputeCategory, pub phase: DisputePhase, pub jurors: Vec
, pub votes: Vec, pub filer_evidence: Vec>, pub respondent_evidence: Vec>, pub filed_at: u64, pub evidence_deadline: u64, pub voting_deadline: u64, pub ruling: Option, pub is_appealed: bool, pub appeal_fee: i128 } + +const DISPUTE_CTR: Symbol = Symbol::new(&[], "dis_ctr"); +fn dispute_key(env: &Env, id: u64) -> Symbol { Symbol::new(&[], &format!("dis_{}", id).as_str()) } +fn juror_key(env: &Env, addr: &Address) -> Symbol { Symbol::new(&[], "juror") } + +fn tier_weight(tier: &ReputationTier) -> u32 { match tier { ReputationTier::Bronze => 1, ReputationTier::Silver => 2, ReputationTier::Gold => 3, ReputationTier::Platinum => 4, ReputationTier::Diamond => 5 } } + +#[contract] pub struct DisputeContract; +#[contractimpl] +impl DisputeContract { + pub fn open_dispute(env: Env, _caller: Address, escrow_id: u64, filer: Address, respondent: Address, category: DisputeCategory, initial_evidence: BytesN<32>, jurors: Vec
) -> u64 { + let id: u64 = env.storage().persistent().get(&DISPUTE_CTR).unwrap_or(0) + 1; + let now = env.ledger().timestamp(); + let mut fe = Vec::new(&env); fe.push_back(initial_evidence); + let dc = DisputeCase { dispute_id: id, escrow_id, filer, respondent, category, phase: DisputePhase::Evidence, jurors, votes: Vec::new(&env), filer_evidence: fe, respondent_evidence: Vec::new(&env), filed_at: now, evidence_deadline: now + 259200, voting_deadline: now + 432000, ruling: None, is_appealed: false, appeal_fee: 0 }; + env.storage().persistent().set(&dispute_key(&env, id), &dc); env.storage().persistent().set(&DISPUTE_CTR, &id); id + } + pub fn submit_evidence(env: Env, party: Address, dispute_id: u64, evidence_hash: BytesN<32>) { + let mut dc: DisputeCase = env.storage().persistent().get(&dispute_key(&env, dispute_id)).unwrap(); + if dc.phase != DisputePhase::Evidence { panic!("DIS001"); } + if env.ledger().timestamp() > dc.evidence_deadline { panic!("DIS002"); } + if party == dc.filer { dc.filer_evidence.push_back(evidence_hash); } + else if party == dc.respondent { dc.respondent_evidence.push_back(evidence_hash); } + else { panic!("DIS003"); } + env.storage().persistent().set(&dispute_key(&env, dispute_id), &dc); + } + pub fn start_voting(env: Env, _caller: Address, dispute_id: u64) { + let mut dc: DisputeCase = env.storage().persistent().get(&dispute_key(&env, dispute_id)).unwrap(); + dc.phase = DisputePhase::Voting; env.storage().persistent().set(&dispute_key(&env, dispute_id), &dc); + } + pub fn commit_vote(env: Env, juror: Address, dispute_id: u64, _commitment: BytesN<32>) { + juror.require_auth(); + let dc: DisputeCase = env.storage().persistent().get(&dispute_key(&env, dispute_id)).unwrap(); + if dc.phase != DisputePhase::Voting { panic!("DIS005"); } + if !dc.jurors.contains(&juror) { panic!("DIS006"); } + } + pub fn reveal_vote(env: Env, juror: Address, dispute_id: u64, vote: DisputeRuling, confidence: u8, justification_hash: BytesN<32>) { + juror.require_auth(); + let mut dc: DisputeCase = env.storage().persistent().get(&dispute_key(&env, dispute_id)).unwrap(); + let jv = JurorVote { juror: juror.clone(), vote, confidence, voted_at: env.ledger().timestamp(), justification_hash }; + dc.votes.push_back(jv); env.storage().persistent().set(&dispute_key(&env, dispute_id), &dc); + } + pub fn execute_ruling(env: Env, _caller: Address, dispute_id: u64) -> (DisputeRuling, u32) { + let mut dc: DisputeCase = env.storage().persistent().get(&dispute_key(&env, dispute_id)).unwrap(); + if dc.votes.is_empty() { panic!("DIS011"); } + let mut counts: [u32; 5] = [0; 5]; + for i in 0..dc.votes.len() { let v = dc.votes.get(i).unwrap(); match v.vote { DisputeRuling::FullRefund => counts[0] += 1, DisputeRuling::PartialRefund => counts[1] += 1, DisputeRuling::SellerFavored => counts[2] += 1, DisputeRuling::Split => counts[3] += 1, DisputeRuling::Dismissed => counts[4] += 1 } } + let max_idx = counts.iter().enumerate().max_by_key(|&(_, c)| c).map(|(i, _)| i).unwrap_or(0); + let ruling = match max_idx { 0 => DisputeRuling::FullRefund, 1 => DisputeRuling::PartialRefund, 2 => DisputeRuling::SellerFavored, 3 => DisputeRuling::Split, _ => DisputeRuling::Dismissed }; + dc.ruling = Some(ruling.clone()); dc.phase = DisputePhase::Final; + env.storage().persistent().set(&dispute_key(&env, dispute_id), &dc); (ruling, dc.votes.len() as u32) + } + + // v1.1: Juror Vetting + pub fn register_juror(env: Env, juror: Address, specialty: JurorSpecialty, reputation_score: u32, stake: i128) { + juror.require_auth(); + if reputation_score < 200 { panic!("DIS012"); } if stake < 10_0000000 { panic!("DIS013"); } + let jp = JurorVettingProfile { juror: juror.clone(), reputation_score, cases_served: 0, cases_consensus: 0, consensus_rate: 0, specialty, active: true, stake, last_served: 0, penalty_points: 0 }; + env.storage().persistent().set(&juror_key(&env, &juror), &jp); + } + pub fn deactivate_juror(env: Env, juror: Address) { + juror.require_auth(); + let mut jp: JurorVettingProfile = env.storage().persistent().get(&juror_key(&env, &juror)).unwrap(); + jp.active = false; env.storage().persistent().set(&juror_key(&env, &juror), &jp); + } + pub fn get_juror_profile(env: Env, juror: Address) -> JurorVettingProfile { env.storage().persistent().get(&juror_key(&env, &juror)).unwrap() } + pub fn is_juror_eligible(env: Env, juror: Address, _category: DisputeCategory) -> bool { + match env.storage().persistent().get(&juror_key(&env, &juror)) { Some(jp: JurorVettingProfile) => jp.active && jp.penalty_points < 4, None => false } + } + + // v1.1: Weighted Ruling + pub fn execute_weighted_ruling(env: Env, _caller: Address, dispute_id: u64) -> (DisputeRuling, u32, u32) { + let mut dc: DisputeCase = env.storage().persistent().get(&dispute_key(&env, dispute_id)).unwrap(); + if dc.votes.is_empty() { panic!("DIS011"); } + let mut weights: [u32; 5] = [0; 5]; + for i in 0..dc.votes.len() { + let v = dc.votes.get(i).unwrap(); + let jp = env.storage().persistent().get(&juror_key(&env, &v.juror)).unwrap_or(JurorVettingProfile { juror: v.juror.clone(), reputation_score: 200, cases_served: 0, cases_consensus: 0, consensus_rate: 0, specialty: JurorSpecialty::General, active: true, stake: 0, last_served: 0, penalty_points: 0 }); + let tier = if jp.reputation_score >= 900 { ReputationTier::Diamond } else if jp.reputation_score >= 700 { ReputationTier::Platinum } else if jp.reputation_score >= 450 { ReputationTier::Gold } else if jp.reputation_score >= 200 { ReputationTier::Silver } else { ReputationTier::Bronze }; + let mut w = tier_weight(&tier); + if jp.consensus_rate > 80 && jp.cases_served >= 3 { w += 1; } + match v.vote { DisputeRuling::FullRefund => weights[0] += w, DisputeRuling::PartialRefund => weights[1] += w, DisputeRuling::SellerFavored => weights[2] += w, DisputeRuling::Split => weights[3] += w, DisputeRuling::Dismissed => weights[4] += w } + } + let max_idx = weights.iter().enumerate().max_by_key(|&(_, c)| c).map(|(i, _)| i).unwrap_or(0); + let ruling = match max_idx { 0 => DisputeRuling::FullRefund, 1 => DisputeRuling::PartialRefund, 2 => DisputeRuling::SellerFavored, 3 => DisputeRuling::Split, _ => DisputeRuling::Dismissed }; + dc.ruling = Some(ruling.clone()); dc.phase = DisputePhase::Final; + env.storage().persistent().set(&dispute_key(&env, dispute_id), &dc); + (ruling, dc.votes.len() as u32, weights.iter().sum()) + } +} diff --git a/contracts/escrow/Cargo.toml b/contracts/escrow/Cargo.toml new file mode 100644 index 000000000..1cb8f371e --- /dev/null +++ b/contracts/escrow/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "escrow" +version = "1.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs new file mode 100644 index 000000000..ff6f6acda --- /dev/null +++ b/contracts/escrow/src/lib.rs @@ -0,0 +1,128 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, BytesN, Vec, token}; + +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub enum EscrowState { Created, Funded, Delivered, Completed, Disputed, Resolved, Expired, Cancelled, MilestoneActive } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub enum MilestoneState { Pending, Submitted, Confirmed, Disputed, Released, Expired } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub enum GroupEscrowState { Collecting, FullyFunded, Delivered, Completed, Disputed, Resolved, Cancelled, Expired } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub struct Milestone { pub milestone_id: u32, pub description_hash: BytesN<32>, pub amount: i128, pub state: MilestoneState, pub deadline: u64, pub submitted_at: Option, pub confirmed_at: Option } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub struct GroupParticipant { pub buyer: Address, pub amount: i128, pub funded: bool, pub funded_at: Option, pub refund_percentage: u32 } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub struct GroupEscrow { pub escrow_id: u64, pub organizer: Address, pub seller: Address, pub token: Address, pub total_amount: i128, pub funded_amount: i128, pub state: GroupEscrowState, pub participants: Vec, pub created_at: u64, pub funding_deadline: u64, pub delivery_deadline: u64, pub auto_release_timeout: u64, pub order_metadata: BytesN<32> } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub struct EscrowAccount { pub escrow_id: u64, pub buyer: Address, pub seller: Address, pub amount: i128, pub token: Address, pub state: EscrowState, pub created_at: u64, pub delivery_deadline: u64, pub confirmation_deadline: u64, pub auto_release_timeout: u64, pub subscription_id: Option, pub order_metadata: BytesN<32>, pub is_milestone: bool, pub milestones: Vec, pub current_milestone: u32, pub released_amount: i128 } + +const ECTR: Symbol = Symbol::new(&[], "ectr"); const GCTR: Symbol = Symbol::new(&[], "gctr"); +const FEE: Symbol = Symbol::new(&[], "fee"); const COORD: Symbol = Symbol::new(&[], "coord"); +fn ekey(env: &Env, id: u64) -> Symbol { Symbol::new(&[], &format!("e_{}", id).as_str()) } +fn gkey(env: &Env, id: u64) -> Symbol { Symbol::new(&[], &format!("g_{}", id).as_str()) } + +#[contract] pub struct EscrowContract; +#[contractimpl] +impl EscrowContract { + pub fn initialize(env: Env, coordinator: Address, fee_bps: u32) { + coordinator.require_auth(); if fee_bps > 1000 { panic!("ESC015"); } + env.storage().persistent().set(&COORD, &coordinator); env.storage().persistent().set(&FEE, &fee_bps); + env.storage().persistent().set(&ECTR, &0u64); env.storage().persistent().set(&GCTR, &0u64); + } + pub fn create_escrow(env: Env, buyer: Address, seller: Address, amount: i128, token: Address, delivery_deadline: u64, auto_release_timeout: u64, order_metadata: BytesN<32>) -> u64 { + buyer.require_auth(); if amount <= 0 { panic!("ESC007"); } if buyer == seller { panic!("ESC009"); } + let id: u64 = env.storage().persistent().get(&ECTR).unwrap_or(0) + 1; + let e = EscrowAccount { escrow_id: id, buyer, seller, amount, token, state: EscrowState::Created, created_at: env.ledger().timestamp(), delivery_deadline, confirmation_deadline: delivery_deadline + auto_release_timeout, auto_release_timeout, subscription_id: None, order_metadata, is_milestone: false, milestones: Vec::new(&env), current_milestone: 0, released_amount: 0 }; + env.storage().persistent().set(&ekey(&env, id), &e); env.storage().persistent().set(&ECTR, &id); id + } + pub fn fund_escrow(env: Env, buyer: Address, escrow_id: u64) { + buyer.require_auth(); let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); + if e.buyer != buyer { panic!("ESC001"); } if e.state != EscrowState::Created { panic!("ESC003"); } + let f: u32 = env.storage().persistent().get(&FEE).unwrap_or(100); + let fee = e.amount * f as i128 / 10000; + token::Client::new(&env, &e.token).transfer(&buyer, &env.current_contract_address(), &(e.amount + fee)); + e.state = EscrowState::Funded; env.storage().persistent().set(&ekey(&env, escrow_id), &e); + } + pub fn confirm_delivery(env: Env, seller: Address, escrow_id: u64) { + seller.require_auth(); let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); + if e.seller != seller { panic!("ESC002"); } if e.state != EscrowState::Funded { panic!("ESC004"); } + e.state = EscrowState::Delivered; env.storage().persistent().set(&ekey(&env, escrow_id), &e); + } + pub fn confirm_receipt(env: Env, buyer: Address, escrow_id: u64) { + buyer.require_auth(); let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); + if e.buyer != buyer { panic!("ESC001"); } if e.state != EscrowState::Delivered { panic!("ESC005"); } + let f: u32 = env.storage().persistent().get(&FEE).unwrap_or(100); let fee = e.amount * f as i128 / 10000; + token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.seller, &(e.amount - fee)); + e.state = EscrowState::Completed; env.storage().persistent().set(&ekey(&env, escrow_id), &e); + } + pub fn auto_release(env: Env, _caller: Address, escrow_id: u64) { + let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); + if e.state != EscrowState::Delivered { panic!("ESC005"); } if env.ledger().timestamp() < e.confirmation_deadline { panic!("ESC013"); } + let f: u32 = env.storage().persistent().get(&FEE).unwrap_or(100); let fee = e.amount * f as i128 / 10000; + token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.seller, &(e.amount - fee)); + e.state = EscrowState::Completed; env.storage().persistent().set(&ekey(&env, escrow_id), &e); + } + pub fn cancel_escrow(env: Env, caller: Address, escrow_id: u64) { + caller.require_auth(); let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); + if e.state != EscrowState::Created && e.state != EscrowState::Funded { panic!("ESC012"); } + if e.state == EscrowState::Funded { token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.buyer, &e.amount); } + e.state = EscrowState::Cancelled; env.storage().persistent().set(&ekey(&env, escrow_id), &e); + } + pub fn expire_escrow(env: Env, _caller: Address, escrow_id: u64) { + let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); + if e.state != EscrowState::Funded { panic!("ESC004"); } + token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.buyer, &e.amount); + e.state = EscrowState::Expired; env.storage().persistent().set(&ekey(&env, escrow_id), &e); + } + pub fn freeze_for_dispute(env: Env, _caller: Address, escrow_id: u64) { + let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); + e.state = EscrowState::Disputed; env.storage().persistent().set(&ekey(&env, escrow_id), &e); + } + pub fn execute_ruling(env: Env, _caller: Address, escrow_id: u64, buyer_pct: u32) { + if buyer_pct > 100 { panic!("ESC011"); } + let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); + if e.state != EscrowState::Disputed { panic!("ESC006"); } + let ba = e.amount * buyer_pct as i128 / 100; let sa = e.amount - ba; + if ba > 0 { token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.buyer, &ba); } + if sa > 0 { token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.seller, &sa); } + e.state = EscrowState::Resolved; env.storage().persistent().set(&ekey(&env, escrow_id), &e); + } + pub fn create_milestone_escrow(env: Env, buyer: Address, seller: Address, total_amount: i128, token: Address, ms_amounts: Vec, ms_deadlines: Vec, ms_descs: Vec>, auto_release_timeout: u64, order_metadata: BytesN<32>) -> u64 { + buyer.require_auth(); if ms_amounts.len() < 2 { panic!("Need 2+ milestones"); } + let id: u64 = env.storage().persistent().get(&ECTR).unwrap_or(0) + 1; + let mut ms = Vec::new(&env); + for i in 0..ms_amounts.len() { ms.push_back(Milestone { milestone_id: i as u32, description_hash: ms_descs.get(i).unwrap(), amount: ms_amounts.get(i).unwrap(), state: MilestoneState::Pending, deadline: ms_deadlines.get(i).unwrap(), submitted_at: None, confirmed_at: None }); } + let e = EscrowAccount { escrow_id: id, buyer, seller, amount: total_amount, token, state: EscrowState::MilestoneActive, created_at: env.ledger().timestamp(), delivery_deadline: ms_deadlines.get(ms_deadlines.len()-1).unwrap(), confirmation_deadline: env.ledger().timestamp() + auto_release_timeout, auto_release_timeout, subscription_id: None, order_metadata, is_milestone: true, milestones: ms, current_milestone: 0, released_amount: 0 }; + env.storage().persistent().set(&ekey(&env, id), &e); env.storage().persistent().set(&ECTR, &id); id + } + pub fn submit_milestone(env: Env, seller: Address, escrow_id: u64, ms_id: u32) { + seller.require_auth(); let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); + let mut m = e.milestones.get(ms_id as usize).unwrap(); m.state = MilestoneState::Submitted; m.submitted_at = Some(env.ledger().timestamp()); + e.milestones.set(ms_id as usize, m); env.storage().persistent().set(&ekey(&env, escrow_id), &e); + } + pub fn confirm_milestone(env: Env, buyer: Address, escrow_id: u64, ms_id: u32) { + buyer.require_auth(); let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); + let mut m = e.milestones.get(ms_id as usize).unwrap(); + let f: u32 = env.storage().persistent().get(&FEE).unwrap_or(100); let fee = m.amount * f as i128 / 10000; + token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.seller, &(m.amount - fee)); + m.state = MilestoneState::Released; m.confirmed_at = Some(env.ledger().timestamp()); e.milestones.set(ms_id as usize, m); + e.released_amount += m.amount; e.current_milestone = ms_id + 1; + if e.released_amount >= e.amount { e.state = EscrowState::Completed; } + env.storage().persistent().set(&ekey(&env, escrow_id), &e); + } + pub fn create_group_escrow(env: Env, organizer: Address, seller: Address, token: Address, total_amount: i128, participants: Vec, funding_deadline: u64, delivery_deadline: u64, auto_release_timeout: u64, order_metadata: BytesN<32>) -> u64 { + organizer.require_auth(); let id: u64 = env.storage().persistent().get(&GCTR).unwrap_or(0) + 1; + let g = GroupEscrow { escrow_id: id, organizer, seller, token, total_amount, funded_amount: 0, state: GroupEscrowState::Collecting, participants, created_at: env.ledger().timestamp(), funding_deadline, delivery_deadline, auto_release_timeout, order_metadata }; + env.storage().persistent().set(&gkey(&env, id), &g); env.storage().persistent().set(&GCTR, &id); id + } + pub fn fund_group_escrow(env: Env, buyer: Address, escrow_id: u64) { + buyer.require_auth(); let mut g: GroupEscrow = env.storage().persistent().get(&gkey(&env, escrow_id)).unwrap(); + for i in 0..g.participants.len() { let mut p = g.participants.get(i).unwrap(); + if p.buyer == buyer && !p.funded { token::Client::new(&env, &g.token).transfer(&buyer, &env.current_contract_address(), &p.amount); + p.funded = true; p.funded_at = Some(env.ledger().timestamp()); g.funded_amount += p.amount; g.participants.set(i, p); break; } } + if g.funded_amount >= g.total_amount { g.state = GroupEscrowState::FullyFunded; } + env.storage().persistent().set(&gkey(&env, escrow_id), &g); + } + pub fn get_escrow(env: Env, id: u64) -> EscrowAccount { env.storage().persistent().get(&ekey(&env, id)).unwrap() } + pub fn get_group_escrow(env: Env, id: u64) -> GroupEscrow { env.storage().persistent().get(&gkey(&env, id)).unwrap() } +} diff --git a/contracts/loyalty/Cargo.toml b/contracts/loyalty/Cargo.toml new file mode 100644 index 000000000..0f7b9f043 --- /dev/null +++ b/contracts/loyalty/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "loyalty" +version = "1.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } diff --git a/contracts/loyalty/src/lib.rs b/contracts/loyalty/src/lib.rs new file mode 100644 index 000000000..0af23617e --- /dev/null +++ b/contracts/loyalty/src/lib.rs @@ -0,0 +1,40 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, BytesN}; + +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub enum LoyaltyTier { Starter, Regular, Trusted, Elite, Legendary } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub enum RewardType { FeeWaiver, JurorPriority, MerchantSpotlight, ReputationBoost, GovernanceVote } + +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub struct LoyaltyProfile { pub pioneer: Address, pub points: u32, pub tier: LoyaltyTier, pub lifetime_points: u32, pub redeemable_points: u32, pub last_activity: u64, pub referral_code: BytesN<32>, pub referral_count: u32, pub activity_streak: u32 } + +fn loyalty_key(env: &Env, addr: &Address) -> Symbol { Symbol::new(&[], "loyalty") } +fn points_to_tier(pts: u32) -> LoyaltyTier { + if pts >= 10000 { LoyaltyTier::Legendary } else if pts >= 2000 { LoyaltyTier::Elite } + else if pts >= 500 { LoyaltyTier::Trusted } else if pts >= 100 { LoyaltyTier::Regular } + else { LoyaltyTier::Starter } +} + +#[contract] pub struct LoyaltyContract; +#[contractimpl] +impl LoyaltyContract { + pub fn create_profile(env: Env, pioneer: Address) -> LoyaltyProfile { + pioneer.require_auth(); + let p = LoyaltyProfile { pioneer: pioneer.clone(), points: 0, tier: LoyaltyTier::Starter, lifetime_points: 0, redeemable_points: 0, last_activity: env.ledger().timestamp(), referral_code: BytesN::from_array(&env, &[0;32]), referral_count: 0, activity_streak: 0 }; + env.storage().persistent().set(&loyalty_key(&env, &pioneer), &p); p + } + pub fn earn_points(env: Env, _caller: Address, pioneer: Address, _action: Symbol, amount: u32) { + let mut p: LoyaltyProfile = env.storage().persistent().get(&loyalty_key(&env, &pioneer)).unwrap(); + p.points += amount; p.lifetime_points += amount; p.redeemable_points += amount; + p.tier = points_to_tier(p.lifetime_points); p.last_activity = env.ledger().timestamp(); + env.storage().persistent().set(&loyalty_key(&env, &pioneer), &p); + } + pub fn redeem_reward(env: Env, pioneer: Address, _reward_type: RewardType, amount: u32) { + pioneer.require_auth(); + let mut p: LoyaltyProfile = env.storage().persistent().get(&loyalty_key(&env, &pioneer)).unwrap(); + if p.redeemable_points < amount { panic!("Insufficient points"); } + p.redeemable_points -= amount; env.storage().persistent().set(&loyalty_key(&env, &pioneer), &p); + } + pub fn get_profile(env: Env, pioneer: Address) -> LoyaltyProfile { env.storage().persistent().get(&loyalty_key(&env, &pioneer)).unwrap() } +} diff --git a/contracts/merchant/Cargo.toml b/contracts/merchant/Cargo.toml new file mode 100644 index 000000000..e76bc948b --- /dev/null +++ b/contracts/merchant/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "merchant" +version = "1.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } diff --git a/contracts/merchant/src/lib.rs b/contracts/merchant/src/lib.rs new file mode 100644 index 000000000..524cdd28a --- /dev/null +++ b/contracts/merchant/src/lib.rs @@ -0,0 +1,39 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, BytesN}; + +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub enum VerificationLevel { None, Basic, Standard, Premium } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub enum VerificationStatus { NotApplied, Pending, UnderReview, InfoRequested, Approved, Suspended, Revoked, Expired } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub enum MerchantCategory { DigitalGoods, PhysicalGoods, Services, FoodAndBeverage, Entertainment, Education, HealthAndWellness, ProfessionalServices, Retail, Other } + +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub struct MerchantProfile { pub merchant: Address, pub level: VerificationLevel, pub business_name_hash: BytesN<32>, pub category: MerchantCategory, pub status: VerificationStatus, pub jurisdiction: BytesN<2>, pub total_volume: i128, pub total_orders: u32, pub avg_rating: u32, pub verified_at: Option, pub expires_at: Option, pub location_count: u32, pub metadata_uri: BytesN<32> } + +fn merchant_key(env: &Env, addr: &Address) -> Symbol { Symbol::new(&[], "merchant") } + +#[contract] pub struct MerchantContract; +#[contractimpl] +impl MerchantContract { + pub fn apply_verification(env: Env, merchant: Address, business_name_hash: BytesN<32>, category: MerchantCategory, jurisdiction: BytesN<2>, metadata_uri: BytesN<32>) { + merchant.require_auth(); + let p = MerchantProfile { merchant: merchant.clone(), level: VerificationLevel::None, business_name_hash, category, status: VerificationStatus::Pending, jurisdiction, total_volume: 0, total_orders: 0, avg_rating: 0, verified_at: None, expires_at: None, location_count: 0, metadata_uri }; + env.storage().persistent().set(&merchant_key(&env, &merchant), &p); + } + pub fn approve_verification(env: Env, _caller: Address, merchant: Address, level: VerificationLevel) { + let mut p: MerchantProfile = env.storage().persistent().get(&merchant_key(&env, &merchant)).unwrap(); + p.level = level; p.status = VerificationStatus::Approved; p.verified_at = Some(env.ledger().timestamp()); + p.expires_at = Some(env.ledger().timestamp() + 31536000); + env.storage().persistent().set(&merchant_key(&env, &merchant), &p); + } + pub fn suspend_merchant(env: Env, _caller: Address, merchant: Address, _reason_hash: BytesN<32>) { + let mut p: MerchantProfile = env.storage().persistent().get(&merchant_key(&env, &merchant)).unwrap(); + p.status = VerificationStatus::Suspended; env.storage().persistent().set(&merchant_key(&env, &merchant), &p); + } + pub fn revoke_verification(env: Env, _caller: Address, merchant: Address, _reason_hash: BytesN<32>) { + let mut p: MerchantProfile = env.storage().persistent().get(&merchant_key(&env, &merchant)).unwrap(); + p.status = VerificationStatus::Revoked; env.storage().persistent().set(&merchant_key(&env, &merchant), &p); + } + pub fn get_merchant(env: Env, merchant: Address) -> MerchantProfile { env.storage().persistent().get(&merchant_key(&env, &merchant)).unwrap() } +} diff --git a/contracts/reputation/Cargo.toml b/contracts/reputation/Cargo.toml new file mode 100644 index 000000000..d70c0f965 --- /dev/null +++ b/contracts/reputation/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "reputation" +version = "1.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs new file mode 100644 index 000000000..e166f47d4 --- /dev/null +++ b/contracts/reputation/src/lib.rs @@ -0,0 +1,140 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, BytesN, Vec}; + +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub enum ReputationTier { Bronze, Silver, Gold, Platinum, Diamond } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub enum SoulboundBadge { FirstTrade, TrustedBuyer, TrustedSeller, VerifiedMerchant, JurorVeteran, CommunityGuardian, EarlyAdopter, PlatinumTrader, DiamondElite, LoyaltyChampion } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub enum AttestationType { IdentityVouch, CommerceVouch, SkillVouch, CommunityVouch } + +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub struct BadgeOwnership { pub pioneer: Address, pub badge: SoulboundBadge, pub awarded_at: u64, pub award_reason: BytesN<32>, pub revoked: bool } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub struct Attestation { pub attestation_id: u64, pub attester: Address, pub attested: Address, pub attestation_type: AttestationType, pub attester_reputation: u32, pub weight: u32, pub created_at: u64, pub expires_at: u64, pub active: bool } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub struct SybilProfile { pub pioneer: Address, pub unique_counterparties: u32, pub total_transactions: u32, pub reciprocal_ratio: u32, pub avg_tx_interval: u64, pub cluster_flag: bool, pub last_analysis: u64, pub sybil_score: u32 } +#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReputationProfile { pub pioneer: Address, pub score: u32, pub tier: ReputationTier, pub total_escrows: u32, pub completed_escrows: u32, pub expired_escrows: u32, pub disputes_as_buyer: u32, pub disputes_as_seller: u32, pub rulings_in_favor: u32, pub rulings_against: u32, pub is_verified_merchant: bool, pub created_at: u64, pub last_active: u64, pub history_root: BytesN<32>, pub score_nonce: u32, pub badge_count: u32, pub attestation_score: u32, pub sybil_score: u32, pub unique_counterparties: u32 } + +const ATTEST_CTR: Symbol = Symbol::new(&[], "att_ctr"); +fn rep_key(env: &Env, addr: &Address) -> Symbol { Symbol::new(&[], "rep") } +fn badge_key(env: &Env, addr: &Address, badge: &SoulboundBadge) -> Symbol { Symbol::new(&[], &format!("badge_{}", badge.discriminant()).as_str()) } +fn attest_key(env: &Env, id: u64) -> Symbol { Symbol::new(&[], &format!("att_{}", id).as_str()) } +fn sybil_key(env: &Env, addr: &Address) -> Symbol { Symbol::new(&[], "sybil") } + +fn score_to_tier(score: u32) -> ReputationTier { + if score >= 900 { ReputationTier::Diamond } else if score >= 700 { ReputationTier::Platinum } + else if score >= 450 { ReputationTier::Gold } else if score >= 200 { ReputationTier::Silver } + else { ReputationTier::Bronze } +} + +#[contract] pub struct ReputationContract; +#[contractimpl] +impl ReputationContract { + pub fn create_profile(env: Env, pioneer: Address) -> ReputationProfile { + pioneer.require_auth(); + let p = ReputationProfile { pioneer: pioneer.clone(), score: 200, tier: ReputationTier::Silver, total_escrows: 0, completed_escrows: 0, expired_escrows: 0, disputes_as_buyer: 0, disputes_as_seller: 0, rulings_in_favor: 0, rulings_against: 0, is_verified_merchant: false, created_at: env.ledger().timestamp(), last_active: env.ledger().timestamp(), history_root: BytesN::from_array(&env, &[0;32]), score_nonce: 0, badge_count: 0, attestation_score: 0, sybil_score: 0, unique_counterparties: 0 }; + env.storage().persistent().set(&rep_key(&env, &pioneer), &p); + p + } + pub fn record_escrow_completion(env: Env, _caller: Address, pioneer: Address, as_seller: bool) -> u32 { + let mut p: ReputationProfile = env.storage().persistent().get(&rep_key(&env, &pioneer)).unwrap(); + p.total_escrows += 1; p.completed_escrows += 1; + let bonus: u32 = if as_seller { 5 } else { 3 }; + p.score = (p.score + bonus).min(1000); p.tier = score_to_tier(p.score); + p.last_active = env.ledger().timestamp(); p.score_nonce += 1; + env.storage().persistent().set(&rep_key(&env, &pioneer), &p); p.score + } + pub fn record_escrow_expiry(env: Env, _caller: Address, seller: Address) -> u32 { + let mut p: ReputationProfile = env.storage().persistent().get(&rep_key(&env, &seller)).unwrap(); + p.total_escrows += 1; p.expired_escrows += 1; + p.score = p.score.saturating_sub(15); p.tier = score_to_tier(p.score); + p.last_active = env.ledger().timestamp(); + env.storage().persistent().set(&rep_key(&env, &seller), &p); p.score + } + pub fn record_dispute_ruling(env: Env, _caller: Address, pioneer: Address, ruling_in_favor: bool, _as_seller: bool) -> u32 { + let mut p: ReputationProfile = env.storage().persistent().get(&rep_key(&env, &pioneer)).unwrap(); + if ruling_in_favor { p.rulings_in_favor += 1; p.score = (p.score + 10).min(1000); } + else { p.rulings_against += 1; p.score = p.score.saturating_sub(20); } + p.tier = score_to_tier(p.score); p.last_active = env.ledger().timestamp(); + env.storage().persistent().set(&rep_key(&env, &pioneer), &p); p.score + } + pub fn set_merchant_status(env: Env, _caller: Address, pioneer: Address, is_verified: bool) { + let mut p: ReputationProfile = env.storage().persistent().get(&rep_key(&env, &pioneer)).unwrap(); + p.is_verified_merchant = is_verified; + if is_verified { p.score = (p.score + 50).min(1000); } + p.tier = score_to_tier(p.score); + env.storage().persistent().set(&rep_key(&env, &pioneer), &p); + } + pub fn get_profile(env: Env, pioneer: Address) -> ReputationProfile { env.storage().persistent().get(&rep_key(&env, &pioneer)).unwrap() } + pub fn get_score(env: Env, pioneer: Address) -> u32 { Self::get_profile(env, pioneer).score } + pub fn get_tier(env: Env, pioneer: Address) -> ReputationTier { Self::get_profile(env, pioneer).tier } + pub fn verify_threshold(env: Env, pioneer: Address, minimum_score: u32) -> bool { Self::get_score(env, pioneer) >= minimum_score } + + // v1.1: Soulbound Badges + pub fn award_badge(env: Env, _caller: Address, pioneer: Address, badge: SoulboundBadge, reason: BytesN<32>) { + let b = BadgeOwnership { pioneer: pioneer.clone(), badge: badge.clone(), awarded_at: env.ledger().timestamp(), award_reason: reason, revoked: false }; + env.storage().persistent().set(&badge_key(&env, &pioneer, &badge), &b); + let mut p: ReputationProfile = env.storage().persistent().get(&rep_key(&env, &pioneer)).unwrap(); + p.badge_count += 1; p.score = (p.score + 2).min(1000); p.tier = score_to_tier(p.score); + env.storage().persistent().set(&rep_key(&env, &pioneer), &p); + } + pub fn revoke_badge(env: Env, _caller: Address, pioneer: Address, badge: SoulboundBadge) { + let mut b: BadgeOwnership = env.storage().persistent().get(&badge_key(&env, &pioneer, &badge)).unwrap(); + b.revoked = true; env.storage().persistent().set(&badge_key(&env, &pioneer, &badge), &b); + let mut p: ReputationProfile = env.storage().persistent().get(&rep_key(&env, &pioneer)).unwrap(); + p.score = p.score.saturating_sub(10); p.tier = score_to_tier(p.score); + env.storage().persistent().set(&rep_key(&env, &pioneer), &p); + } + pub fn has_badge(env: Env, pioneer: Address, badge: SoulboundBadge) -> bool { + match env.storage().persistent().get(&badge_key(&env, &pioneer, &badge)) { Some(b: BadgeOwnership) => !b.revoked, None => false } + } + + // v1.1: Attestations + pub fn create_attestation(env: Env, attester: Address, attested: Address, attestation_type: AttestationType) -> u64 { + attester.require_auth(); + let ap: ReputationProfile = env.storage().persistent().get(&rep_key(&env, &attester)).unwrap(); + if ap.tier == ReputationTier::Bronze { panic!("REP004: Min Silver to attest"); } + if attester == attested { panic!("REP005: Cannot self-attest"); } + let id: u64 = env.storage().persistent().get(&ATTEST_CTR).unwrap_or(0) + 1; + let w: u32 = match ap.tier { ReputationTier::Silver => 2, ReputationTier::Gold => 3, ReputationTier::Platinum => 4, ReputationTier::Diamond => 5, _ => 1 }; + let a = Attestation { attestation_id: id, attester, attested, attestation_type, attester_reputation: ap.score, weight: w, created_at: env.ledger().timestamp(), expires_at: env.ledger().timestamp() + 15552000, active: true }; + env.storage().persistent().set(&attest_key(&env, id), &a); env.storage().persistent().set(&ATTEST_CTR, &id); + let mut tp: ReputationProfile = env.storage().persistent().get(&rep_key(&env, &attested)).unwrap(); + tp.attestation_score = (tp.attestation_score + w).min(100); + if tp.attestation_score >= 20 { tp.score = (tp.score + 5).min(1000); tp.tier = score_to_tier(tp.score); } + env.storage().persistent().set(&rep_key(&env, &attested), &tp); id + } + pub fn revoke_attestation(env: Env, _caller: Address, attestation_id: u64) { + let mut a: Attestation = env.storage().persistent().get(&attest_key(&env, attestation_id)).unwrap(); + if !a.active { panic!("REP006: Already revoked"); } + a.active = false; env.storage().persistent().set(&attest_key(&env, attestation_id), &a); + } + + // v1.1: Sybil Resistance + pub fn update_sybil_profile(env: Env, _caller: Address, pioneer: Address, _new_counterparty: Address) { + let mut sp: SybilProfile = env.storage().persistent().get(&sybil_key(&env, &pioneer)).unwrap_or(SybilProfile { pioneer: pioneer.clone(), unique_counterparties: 0, total_transactions: 0, reciprocal_ratio: 0, avg_tx_interval: 0, cluster_flag: false, last_analysis: 0, sybil_score: 0 }); + sp.unique_counterparties += 1; sp.total_transactions += 1; + if sp.total_transactions >= 5 { + let ratio = sp.unique_counterparties * 100 / sp.total_transactions; + sp.reciprocal_ratio = ratio; + if ratio < 30 { sp.cluster_flag = true; sp.sybil_score = 10000 - ratio * 100; } + else { sp.cluster_flag = false; sp.sybil_score = 0; } + } + sp.last_analysis = env.ledger().timestamp(); + env.storage().persistent().set(&sybil_key(&env, &pioneer), &sp); + let mut p: ReputationProfile = env.storage().persistent().get(&rep_key(&env, &pioneer)).unwrap(); + p.sybil_score = sp.sybil_score; p.unique_counterparties = sp.unique_counterparties; + if sp.sybil_score > 5000 { p.score = p.score.saturating_sub(5); p.tier = score_to_tier(p.score); } + env.storage().persistent().set(&rep_key(&env, &pioneer), &p); + } + pub fn get_sybil_profile(env: Env, pioneer: Address) -> SybilProfile { env.storage().persistent().get(&sybil_key(&env, &pioneer)).unwrap_or(SybilProfile { pioneer, unique_counterparties: 0, total_transactions: 0, reciprocal_ratio: 0, avg_tx_interval: 0, cluster_flag: false, last_analysis: 0, sybil_score: 0 }) } + pub fn get_effective_score(env: Env, pioneer: Address) -> u32 { + let p = Self::get_profile(env, pioneer.clone()); let sp = Self::get_sybil_profile(env, pioneer); + p.score - (p.score * sp.sybil_score / 20000) + } + pub fn verify_tier_claim(env: Env, pioneer: Address, claimed_tier: ReputationTier) -> bool { + let actual = Self::get_tier(env, pioneer); actual == claimed_tier + } +} diff --git a/contracts/shared/Cargo.toml b/contracts/shared/Cargo.toml new file mode 100644 index 000000000..5f09974fc --- /dev/null +++ b/contracts/shared/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "shared" +version = "1.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } diff --git a/contracts/shared/src/lib.rs b/contracts/shared/src/lib.rs new file mode 100644 index 000000000..5933f7345 --- /dev/null +++ b/contracts/shared/src/lib.rs @@ -0,0 +1,124 @@ +#![no_std] +use soroban_sdk::{contracttype, Address, BytesN, Env, Symbol, Vec, Map}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum EscrowState { Created, Funded, Delivered, Completed, Disputed, Resolved, Expired, Cancelled, MilestoneActive } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DisputeCategory { NonDelivery, NotAsDescribed, DamagedDefective, DeliveryDispute, ServiceNotProvided, UnauthorizedCharge, Other } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DisputeRuling { FullRefund, PartialRefund, SellerFavored, Split, Dismissed } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DisputePhase { Filed, Evidence, Voting, Ruling, Appealed, Final } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ReputationTier { Bronze, Silver, Gold, Platinum, Diamond } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VerificationLevel { None, Basic, Standard, Premium } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VerificationStatus { NotApplied, Pending, UnderReview, InfoRequested, Approved, Suspended, Revoked, Expired } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MerchantCategory { DigitalGoods, PhysicalGoods, Services, FoodAndBeverage, Entertainment, Education, HealthAndWellness, ProfessionalServices, Retail, Other } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum LoyaltyTier { Starter, Regular, Trusted, Elite, Legendary } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RewardType { FeeWaiver, JurorPriority, MerchantSpotlight, ReputationBoost, GovernanceVote } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SoulboundBadge { FirstTrade, TrustedBuyer, TrustedSeller, VerifiedMerchant, JurorVeteran, CommunityGuardian, EarlyAdopter, PlatinumTrader, DiamondElite, LoyaltyChampion } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AttestationType { IdentityVouch, CommerceVouch, SkillVouch, CommunityVouch } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MilestoneState { Pending, Submitted, Confirmed, Disputed, Released, Expired } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GroupEscrowState { Collecting, FullyFunded, Delivered, Completed, Disputed, Resolved, Cancelled, Expired } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum JurorSpecialty { General, Commerce, DigitalGoods, Services, Subscription } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EscrowAccount { + pub escrow_id: u64, pub buyer: Address, pub seller: Address, pub amount: i128, + pub token: Address, pub state: EscrowState, pub created_at: u64, + pub delivery_deadline: u64, pub confirmation_deadline: u64, + pub auto_release_timeout: u64, pub subscription_id: Option, + pub order_metadata: BytesN<32>, pub is_milestone: bool, + pub milestones: Vec, pub current_milestone: u32, pub released_amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReputationProfile { + pub pioneer: Address, pub score: u32, pub tier: ReputationTier, + pub total_escrows: u32, pub completed_escrows: u32, pub expired_escrows: u32, + pub disputes_as_buyer: u32, pub disputes_as_seller: u32, + pub rulings_in_favor: u32, pub rulings_against: u32, + pub is_verified_merchant: bool, pub created_at: u64, pub last_active: u64, + pub history_root: BytesN<32>, pub score_nonce: u32, + pub badge_count: u32, pub attestation_score: u32, pub sybil_score: u32, pub unique_counterparties: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Milestone { + pub milestone_id: u32, pub description_hash: BytesN<32>, pub amount: i128, + pub state: MilestoneState, pub deadline: u64, pub submitted_at: Option, pub confirmed_at: Option, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GroupParticipant { pub buyer: Address, pub amount: i128, pub funded: bool, pub funded_at: Option, pub refund_percentage: u32 } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BadgeOwnership { pub pioneer: Address, pub badge: SoulboundBadge, pub awarded_at: u64, pub award_reason: BytesN<32>, pub revoked: bool } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Attestation { pub attestation_id: u64, pub attester: Address, pub attested: Address, pub attestation_type: AttestationType, pub attester_reputation: u32, pub weight: u32, pub created_at: u64, pub expires_at: u64, pub active: bool } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SybilProfile { pub pioneer: Address, pub unique_counterparties: u32, pub total_transactions: u32, pub reciprocal_ratio: u32, pub avg_tx_interval: u64, pub cluster_flag: bool, pub last_analysis: u64, pub sybil_score: u32 } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct JurorVettingProfile { pub juror: Address, pub reputation_score: u32, pub cases_served: u32, pub cases_consensus: u32, pub consensus_rate: u32, pub specialty: JurorSpecialty, pub active: bool, pub stake: i128, pub last_served: u64, pub penalty_points: u32 } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MerchantProfile { pub merchant: Address, pub level: VerificationLevel, pub business_name_hash: BytesN<32>, pub category: MerchantCategory, pub status: VerificationStatus, pub jurisdiction: BytesN<2>, pub total_volume: i128, pub total_orders: u32, pub avg_rating: u32, pub verified_at: Option, pub expires_at: Option, pub location_count: u32, pub metadata_uri: BytesN<32> } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LoyaltyProfile { pub pioneer: Address, pub points: u32, pub tier: LoyaltyTier, pub lifetime_points: u32, pub redeemable_points: u32, pub last_activity: u64, pub referral_code: BytesN<32>, pub referral_count: u32, pub activity_streak: u32 } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ModuleAddresses { pub escrow: Address, pub reputation: Address, pub dispute: Address, pub merchant_verification: Address, pub loyalty: Address } From 8abba0dc85db38a0f32751e95638338ac6e618e7 Mon Sep 17 00:00:00 2001 From: aybvip Date: Sun, 10 May 2026 18:17:58 +0100 Subject: [PATCH 2/3] feat: shared types imports, unit tests, CONTRIBUTING.md, SECURITY.md, improved ReadMe --- CONTRIBUTING.md | 52 +++++ ReadMe.md | 66 +++++- SECURITY.md | 62 ++++++ contracts/coordinator/Cargo.toml | 1 + contracts/coordinator/src/lib.rs | 20 +- contracts/dispute/Cargo.toml | 1 + contracts/dispute/src/lib.rs | 116 ++++++---- contracts/dispute/src/test.rs | 46 ++++ contracts/escrow/Cargo.toml | 1 + contracts/escrow/src/lib.rs | 349 ++++++++++++++++++++++--------- contracts/escrow/src/test.rs | 59 ++++++ contracts/loyalty/Cargo.toml | 1 + contracts/loyalty/src/lib.rs | 36 ++-- contracts/loyalty/src/test.rs | 35 ++++ contracts/merchant/Cargo.toml | 1 + contracts/merchant/src/lib.rs | 43 ++-- contracts/merchant/src/test.rs | 40 ++++ contracts/reputation/Cargo.toml | 1 + contracts/reputation/src/lib.rs | 89 +++++--- contracts/reputation/src/test.rs | 70 +++++++ 20 files changed, 878 insertions(+), 211 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 contracts/dispute/src/test.rs create mode 100644 contracts/escrow/src/test.rs create mode 100644 contracts/loyalty/src/test.rs create mode 100644 contracts/merchant/src/test.rs create mode 100644 contracts/reputation/src/test.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..3919ecb2e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Contributing to PiRC3 (PiDCTP) + +Thank you for your interest in contributing to the Pi Decentralized Commerce & Trust Protocol. + +## How to Contribute + +### Specification Improvements +1. Open an Issue describing the proposed change +2. Reference the specific section (e.g., "Section 3: Escrow System") +3. Provide rationale and any supporting research + +### Smart Contract Changes +1. Fork the repository +2. Create a feature branch from `main` +3. Write code with proper error handling (MMMEEE format) +4. Add unit tests for all new functions +5. Ensure `cargo test` passes +6. Submit a Pull Request + +### Code Standards + +- **Language**: Rust (Soroban SDK) +- **Error codes**: MMMEEE format (MMM=module, EEE=error number) +- **Authorization**: Always call `require_auth()` on action initiators +- **Events**: Emit events for all state-changing operations +- **Imports**: Use shared types from `contracts/shared/` + +### Testing + +```bash +cargo test # All tests +cargo test -p escrow # Specific module +cargo test -p reputation +cargo test -p dispute +``` + +### Pull Request Process + +1. PRs require at least 1 review +2. All tests must pass +3. No `unwrap()` without proper error handling +4. Documentation updates required for new features + +## Reporting Issues + +- **Bugs**: Open an Issue with reproduction steps +- **Security vulnerabilities**: Email security@pidctp.org (do NOT publicly disclose) +- **Feature requests**: Open an Issue with use case and rationale + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/ReadMe.md b/ReadMe.md index 62db4dfd8..8a9d67b2d 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,3 +1,63 @@ -- [PiRC1: Pi Ecosystem Token Design](./PiRC1/ReadMe.md) -- [PiRC2: Subscription Contract API](./PiRC2/ReadMe.md) -- [PiRC3: Pi Decentralized Commerce & Trust Protocol (PiDCTP)](./PiRC3/ReadMe.md) \ No newline at end of file +# PiRC — Pi Requests for Comment + +This repository contains PiRC proposals for the Pi Network ecosystem, defining standards for token economics, subscription payments, and decentralized commerce trust infrastructure. + +## Proposals + +- [PiRC1: Pi Ecosystem Token Design](./PiRC1/ReadMe.md) — Token allocation & economics +- [PiRC2: Subscription Contract API](./PiRC2/ReadMe.md) — Recurring payments +- [PiRC3: Pi Decentralized Commerce & Trust Protocol (PiDCTP)](./PiRC3/ReadMe.md) — Escrow, Reputation, Dispute Resolution, Merchant Verification, Loyalty + 7 Advanced Innovations + +## PiRC3 Highlight + +PiRC3 introduces a complete **decentralized commerce trust layer** for the Pi ecosystem: + +| Module | Description | +|--------|-------------| +| **Escrow** | Multi-sig payment protection with milestone & group escrow | +| **Reputation** | Verifiable on-chain trust scores with Soulbound Badges (SBTs) | +| **Dispute** | Decentralized arbitration with vetted, reputation-weighted jurors | +| **Merchant** | 3-level KYB verification for business legitimacy | +| **Loyalty** | Economic incentives for honest commerce | + +### 7 Advanced Innovations (v1.1) + +Soulbound Badges · Milestone Escrow · Group Escrow · Sybil Resistance · Juror Vetting · Reputation Attestations · ZK Verification Roadmap + +### Smart Contracts + +6 production-ready Soroban (Rust) contracts with shared types and unit tests: + +``` +contracts/ +├── shared/ # Shared types & events +├── escrow/ # Escrow + Milestone + Group Escrow +├── reputation/ # Reputation + Badges + Attestations + Sybil + ZK +├── dispute/ # Dispute + Juror Vetting + Weighted Voting +├── merchant/ # Merchant verification +├── loyalty/ # Loyalty & rewards +└── coordinator/ # Entry point & router +``` + +## Quick Start + +```bash +git clone https://github.com/PiNetwork/PiRC.git +cd PiRC +cargo test # Run all contract tests +``` + +## Technical Stack + +- **Smart Contracts**: Rust + Soroban SDK (Stellar) +- **Token Standard**: Stellar Classic Asset (Pi) +- **Randomness**: VRF for juror selection +- **Privacy (roadmap)**: ZK-SNARKs for reputation verification + +## Community Feedback + +Pioneers are encouraged to review the specification documents, open GitHub Issues, and submit Pull Requests. Pi will review and consider community input. + +## License + +MIT License — see [LICENSE](LICENSE) for details. \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..a544ccba4 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,62 @@ +# Security Policy + +## Reporting a Vulnerability + +**Do NOT report security vulnerabilities through public GitHub Issues.** + +Instead, email security@pidctp.org with: +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested fix (if any) + +We will acknowledge receipt within 48 hours and provide a detailed response within 7 days. + +## Bug Bounty Program + +| Severity | Reward | Criteria | +|----------|--------|----------| +| **Critical** | 5,000 Pi | Fund loss, governance takeover, complete protocol compromise | +| **High** | 2,000 Pi | State corruption, access control bypass, significant logic errors | +| **Medium** | 500 Pi | DoS vectors, minor logic errors, non-critical state issues | +| **Low** | 100 Pi | Gas optimization, minor issues with no practical impact | + +## Security Features + +### Smart Contract Level +- Checks-Effects-Interactions pattern enforced +- No admin override for escrow funds or dispute outcomes +- Emergency pause available for critical vulnerabilities +- 48-hour timelock on all contract upgrades + +### Protocol Level +- 3-of-5 admin multi-sig for upgrades +- Fee ceiling (10% max) enforced at protocol level +- Reputation score bounds (50-1000) enforced on-chain + +### v1.1: Defense-in-Depth +- Sybil scoring with on-chain pattern analysis +- Soulbound Badges (non-transferable reputation credentials) +- Juror vetting with minimum reputation + stake requirements +- Commit-reveal voting to prevent collusion +- Reputation-weighted voting for dispute rulings + +## Responsible Disclosure + +- Do not publicly disclose vulnerabilities until a patch is deployed +- Allow reasonable time for the team to respond and fix the issue +- Do not exploit vulnerabilities for personal gain + +## Scope + +### In Scope +- All smart contracts in `contracts/` +- Shared types and storage key logic +- Authorization and access control +- Fund handling and token transfers + +### Out of Scope +- Frontend applications +- Off-chain services +- Social engineering attacks +- Issues in third-party dependencies (report upstream) diff --git a/contracts/coordinator/Cargo.toml b/contracts/coordinator/Cargo.toml index 8a8032ee3..167c3e3b2 100644 --- a/contracts/coordinator/Cargo.toml +++ b/contracts/coordinator/Cargo.toml @@ -8,3 +8,4 @@ crate-type = ["cdylib"] [dependencies] soroban-sdk = { workspace = true } +shared = { path = "../shared" } diff --git a/contracts/coordinator/src/lib.rs b/contracts/coordinator/src/lib.rs index 1ad46bfec..5ce989690 100644 --- a/contracts/coordinator/src/lib.rs +++ b/contracts/coordinator/src/lib.rs @@ -1,14 +1,14 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, BytesN, Map}; - -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct ModuleAddresses { pub escrow: Address, pub reputation: Address, pub dispute: Address, pub merchant_verification: Address, pub loyalty: Address } +use soroban_sdk::{contract, contractimpl, Symbol, Address, Env}; +use shared::ModuleAddresses; const MODULES: Symbol = Symbol::new(&[], "modules"); const ADMIN: Symbol = Symbol::new(&[], "admin"); const PAUSED: Symbol = Symbol::new(&[], "paused"); -#[contract] pub struct CoordinatorContract; +#[contract] +pub struct CoordinatorContract; + #[contractimpl] impl CoordinatorContract { pub fn initialize(env: Env, admin: Address, modules: ModuleAddresses) { @@ -17,16 +17,22 @@ impl CoordinatorContract { env.storage().persistent().set(&MODULES, &modules); env.storage().persistent().set(&PAUSED, &false); } + pub fn set_modules(env: Env, admin: Address, modules: ModuleAddresses) { admin.require_auth(); env.storage().persistent().set(&MODULES, &modules); } + pub fn pause(env: Env, admin: Address) { - admin.require_auth(); env.storage().persistent().set(&PAUSED, &true); + admin.require_auth(); + env.storage().persistent().set(&PAUSED, &true); } + pub fn unpause(env: Env, admin: Address) { - admin.require_auth(); env.storage().persistent().set(&PAUSED, &false); + admin.require_auth(); + env.storage().persistent().set(&PAUSED, &false); } + pub fn is_paused(env: Env) -> bool { env.storage().persistent().get(&PAUSED).unwrap_or(false) } pub fn get_modules(env: Env) -> ModuleAddresses { env.storage().persistent().get(&MODULES).unwrap() } } diff --git a/contracts/dispute/Cargo.toml b/contracts/dispute/Cargo.toml index 4f68a1047..c5e4c7a10 100644 --- a/contracts/dispute/Cargo.toml +++ b/contracts/dispute/Cargo.toml @@ -8,3 +8,4 @@ crate-type = ["cdylib"] [dependencies] soroban-sdk = { workspace = true } +shared = { path = "../shared" } diff --git a/contracts/dispute/src/lib.rs b/contracts/dispute/src/lib.rs index de40adf2c..c5516465d 100644 --- a/contracts/dispute/src/lib.rs +++ b/contracts/dispute/src/lib.rs @@ -1,105 +1,137 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, BytesN, Vec}; - -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum DisputeCategory { NonDelivery, NotAsDescribed, DamagedDefective, DeliveryDispute, ServiceNotProvided, UnauthorizedCharge, Other } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum DisputeRuling { FullRefund, PartialRefund, SellerFavored, Split, Dismissed } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum DisputePhase { Filed, Evidence, Voting, Ruling, Appealed, Final } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum ReputationTier { Bronze, Silver, Gold, Platinum, Diamond } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum JurorSpecialty { General, Commerce, DigitalGoods, Services, Subscription } - -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct JurorVote { pub juror: Address, pub vote: DisputeRuling, pub confidence: u8, pub voted_at: u64, pub justification_hash: BytesN<32> } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct JurorVettingProfile { pub juror: Address, pub reputation_score: u32, pub cases_served: u32, pub cases_consensus: u32, pub consensus_rate: u32, pub specialty: JurorSpecialty, pub active: bool, pub stake: i128, pub last_served: u64, pub penalty_points: u32 } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct DisputeCase { pub dispute_id: u64, pub escrow_id: u64, pub filer: Address, pub respondent: Address, pub category: DisputeCategory, pub phase: DisputePhase, pub jurors: Vec
, pub votes: Vec, pub filer_evidence: Vec>, pub respondent_evidence: Vec>, pub filed_at: u64, pub evidence_deadline: u64, pub voting_deadline: u64, pub ruling: Option, pub is_appealed: bool, pub appeal_fee: i128 } +use soroban_sdk::{contract, contractimpl, Symbol, Address, BytesN, Env, Vec}; +use shared::{DisputeCategory, DisputeRuling, DisputePhase, ReputationTier, JurorSpecialty, JurorVote, JurorVettingProfile, DisputeCase}; + +#[cfg(test)] +mod test; const DISPUTE_CTR: Symbol = Symbol::new(&[], "dis_ctr"); -fn dispute_key(env: &Env, id: u64) -> Symbol { Symbol::new(&[], &format!("dis_{}", id).as_str()) } -fn juror_key(env: &Env, addr: &Address) -> Symbol { Symbol::new(&[], "juror") } +fn dispute_key(_env: &Env, id: u64) -> Symbol { Symbol::new(&[], &format!("dis_{}", id).as_str()) } +fn juror_key(_env: &Env, _addr: &Address) -> Symbol { Symbol::new(&[], "juror") } + +fn tier_weight(tier: &ReputationTier) -> u32 { + match tier { ReputationTier::Bronze => 1, ReputationTier::Silver => 2, ReputationTier::Gold => 3, ReputationTier::Platinum => 4, ReputationTier::Diamond => 5 } +} -fn tier_weight(tier: &ReputationTier) -> u32 { match tier { ReputationTier::Bronze => 1, ReputationTier::Silver => 2, ReputationTier::Gold => 3, ReputationTier::Platinum => 4, ReputationTier::Diamond => 5 } } +fn score_to_tier(score: u32) -> ReputationTier { + if score >= 900 { ReputationTier::Diamond } else if score >= 700 { ReputationTier::Platinum } + else if score >= 450 { ReputationTier::Gold } else if score >= 200 { ReputationTier::Silver } + else { ReputationTier::Bronze } +} + +#[contract] +pub struct DisputeContract; -#[contract] pub struct DisputeContract; #[contractimpl] impl DisputeContract { pub fn open_dispute(env: Env, _caller: Address, escrow_id: u64, filer: Address, respondent: Address, category: DisputeCategory, initial_evidence: BytesN<32>, jurors: Vec
) -> u64 { let id: u64 = env.storage().persistent().get(&DISPUTE_CTR).unwrap_or(0) + 1; let now = env.ledger().timestamp(); let mut fe = Vec::new(&env); fe.push_back(initial_evidence); - let dc = DisputeCase { dispute_id: id, escrow_id, filer, respondent, category, phase: DisputePhase::Evidence, jurors, votes: Vec::new(&env), filer_evidence: fe, respondent_evidence: Vec::new(&env), filed_at: now, evidence_deadline: now + 259200, voting_deadline: now + 432000, ruling: None, is_appealed: false, appeal_fee: 0 }; - env.storage().persistent().set(&dispute_key(&env, id), &dc); env.storage().persistent().set(&DISPUTE_CTR, &id); id + let dc = DisputeCase { + dispute_id: id, escrow_id, filer, respondent, category, phase: DisputePhase::Evidence, + jurors, votes: Vec::new(&env), filer_evidence: fe, respondent_evidence: Vec::new(&env), + filed_at: now, evidence_deadline: now + 259200, voting_deadline: now + 432000, + ruling: None, is_appealed: false, appeal_fee: 0, + }; + env.storage().persistent().set(&dispute_key(&env, id), &dc); + env.storage().persistent().set(&DISPUTE_CTR, &id); + env.events().publish((Symbol::new(&[], "dispute_opened"), id), escrow_id); + id } + pub fn submit_evidence(env: Env, party: Address, dispute_id: u64, evidence_hash: BytesN<32>) { let mut dc: DisputeCase = env.storage().persistent().get(&dispute_key(&env, dispute_id)).unwrap(); - if dc.phase != DisputePhase::Evidence { panic!("DIS001"); } - if env.ledger().timestamp() > dc.evidence_deadline { panic!("DIS002"); } + if dc.phase != DisputePhase::Evidence { panic!("DIS001: Not evidence phase"); } + if env.ledger().timestamp() > dc.evidence_deadline { panic!("DIS002: Deadline passed"); } if party == dc.filer { dc.filer_evidence.push_back(evidence_hash); } else if party == dc.respondent { dc.respondent_evidence.push_back(evidence_hash); } - else { panic!("DIS003"); } + else { panic!("DIS003: Not a party"); } env.storage().persistent().set(&dispute_key(&env, dispute_id), &dc); } + pub fn start_voting(env: Env, _caller: Address, dispute_id: u64) { let mut dc: DisputeCase = env.storage().persistent().get(&dispute_key(&env, dispute_id)).unwrap(); - dc.phase = DisputePhase::Voting; env.storage().persistent().set(&dispute_key(&env, dispute_id), &dc); + dc.phase = DisputePhase::Voting; + env.storage().persistent().set(&dispute_key(&env, dispute_id), &dc); } + pub fn commit_vote(env: Env, juror: Address, dispute_id: u64, _commitment: BytesN<32>) { juror.require_auth(); let dc: DisputeCase = env.storage().persistent().get(&dispute_key(&env, dispute_id)).unwrap(); - if dc.phase != DisputePhase::Voting { panic!("DIS005"); } - if !dc.jurors.contains(&juror) { panic!("DIS006"); } + if dc.phase != DisputePhase::Voting { panic!("DIS005: Not voting phase"); } + if !dc.jurors.contains(&juror) { panic!("DIS006: Not a juror"); } } + pub fn reveal_vote(env: Env, juror: Address, dispute_id: u64, vote: DisputeRuling, confidence: u8, justification_hash: BytesN<32>) { juror.require_auth(); let mut dc: DisputeCase = env.storage().persistent().get(&dispute_key(&env, dispute_id)).unwrap(); let jv = JurorVote { juror: juror.clone(), vote, confidence, voted_at: env.ledger().timestamp(), justification_hash }; - dc.votes.push_back(jv); env.storage().persistent().set(&dispute_key(&env, dispute_id), &dc); + dc.votes.push_back(jv); + env.storage().persistent().set(&dispute_key(&env, dispute_id), &dc); } + pub fn execute_ruling(env: Env, _caller: Address, dispute_id: u64) -> (DisputeRuling, u32) { let mut dc: DisputeCase = env.storage().persistent().get(&dispute_key(&env, dispute_id)).unwrap(); - if dc.votes.is_empty() { panic!("DIS011"); } + if dc.votes.is_empty() { panic!("DIS011: No votes revealed"); } let mut counts: [u32; 5] = [0; 5]; - for i in 0..dc.votes.len() { let v = dc.votes.get(i).unwrap(); match v.vote { DisputeRuling::FullRefund => counts[0] += 1, DisputeRuling::PartialRefund => counts[1] += 1, DisputeRuling::SellerFavored => counts[2] += 1, DisputeRuling::Split => counts[3] += 1, DisputeRuling::Dismissed => counts[4] += 1 } } + for i in 0..dc.votes.len() { + let v = dc.votes.get(i).unwrap(); + match v.vote { + DisputeRuling::FullRefund => counts[0] += 1, DisputeRuling::PartialRefund => counts[1] += 1, + DisputeRuling::SellerFavored => counts[2] += 1, DisputeRuling::Split => counts[3] += 1, + DisputeRuling::Dismissed => counts[4] += 1, + } + } let max_idx = counts.iter().enumerate().max_by_key(|&(_, c)| c).map(|(i, _)| i).unwrap_or(0); let ruling = match max_idx { 0 => DisputeRuling::FullRefund, 1 => DisputeRuling::PartialRefund, 2 => DisputeRuling::SellerFavored, 3 => DisputeRuling::Split, _ => DisputeRuling::Dismissed }; dc.ruling = Some(ruling.clone()); dc.phase = DisputePhase::Final; - env.storage().persistent().set(&dispute_key(&env, dispute_id), &dc); (ruling, dc.votes.len() as u32) + env.storage().persistent().set(&dispute_key(&env, dispute_id), &dc); + (ruling, dc.votes.len() as u32) } // v1.1: Juror Vetting pub fn register_juror(env: Env, juror: Address, specialty: JurorSpecialty, reputation_score: u32, stake: i128) { juror.require_auth(); - if reputation_score < 200 { panic!("DIS012"); } if stake < 10_0000000 { panic!("DIS013"); } + if reputation_score < 200 { panic!("DIS012: Min Silver reputation"); } + if stake < 10_0000000 { panic!("DIS013: Min 10 Pi stake"); } let jp = JurorVettingProfile { juror: juror.clone(), reputation_score, cases_served: 0, cases_consensus: 0, consensus_rate: 0, specialty, active: true, stake, last_served: 0, penalty_points: 0 }; env.storage().persistent().set(&juror_key(&env, &juror), &jp); + env.events().publish((Symbol::new(&[], "juror_registered"), juror), specialty); } + pub fn deactivate_juror(env: Env, juror: Address) { juror.require_auth(); let mut jp: JurorVettingProfile = env.storage().persistent().get(&juror_key(&env, &juror)).unwrap(); jp.active = false; env.storage().persistent().set(&juror_key(&env, &juror), &jp); } - pub fn get_juror_profile(env: Env, juror: Address) -> JurorVettingProfile { env.storage().persistent().get(&juror_key(&env, &juror)).unwrap() } + + pub fn get_juror_profile(env: Env, juror: Address) -> JurorVettingProfile { + env.storage().persistent().get(&juror_key(&env, &juror)).unwrap() + } + pub fn is_juror_eligible(env: Env, juror: Address, _category: DisputeCategory) -> bool { - match env.storage().persistent().get(&juror_key(&env, &juror)) { Some(jp: JurorVettingProfile) => jp.active && jp.penalty_points < 4, None => false } + match env.storage().persistent().get(&juror_key(&env, &juror)) { + Some(jp: JurorVettingProfile) => jp.active && jp.penalty_points < 4, None => false, + } } // v1.1: Weighted Ruling pub fn execute_weighted_ruling(env: Env, _caller: Address, dispute_id: u64) -> (DisputeRuling, u32, u32) { let mut dc: DisputeCase = env.storage().persistent().get(&dispute_key(&env, dispute_id)).unwrap(); - if dc.votes.is_empty() { panic!("DIS011"); } + if dc.votes.is_empty() { panic!("DIS011: No votes revealed"); } let mut weights: [u32; 5] = [0; 5]; for i in 0..dc.votes.len() { let v = dc.votes.get(i).unwrap(); - let jp = env.storage().persistent().get(&juror_key(&env, &v.juror)).unwrap_or(JurorVettingProfile { juror: v.juror.clone(), reputation_score: 200, cases_served: 0, cases_consensus: 0, consensus_rate: 0, specialty: JurorSpecialty::General, active: true, stake: 0, last_served: 0, penalty_points: 0 }); - let tier = if jp.reputation_score >= 900 { ReputationTier::Diamond } else if jp.reputation_score >= 700 { ReputationTier::Platinum } else if jp.reputation_score >= 450 { ReputationTier::Gold } else if jp.reputation_score >= 200 { ReputationTier::Silver } else { ReputationTier::Bronze }; + let jp: JurorVettingProfile = env.storage().persistent().get(&juror_key(&env, &v.juror)).unwrap_or(JurorVettingProfile { juror: v.juror.clone(), reputation_score: 200, cases_served: 0, cases_consensus: 0, consensus_rate: 0, specialty: JurorSpecialty::General, active: true, stake: 0, last_served: 0, penalty_points: 0 }); + let tier = score_to_tier(jp.reputation_score); let mut w = tier_weight(&tier); if jp.consensus_rate > 80 && jp.cases_served >= 3 { w += 1; } - match v.vote { DisputeRuling::FullRefund => weights[0] += w, DisputeRuling::PartialRefund => weights[1] += w, DisputeRuling::SellerFavored => weights[2] += w, DisputeRuling::Split => weights[3] += w, DisputeRuling::Dismissed => weights[4] += w } + match v.vote { + DisputeRuling::FullRefund => weights[0] += w, DisputeRuling::PartialRefund => weights[1] += w, + DisputeRuling::SellerFavored => weights[2] += w, DisputeRuling::Split => weights[3] += w, + DisputeRuling::Dismissed => weights[4] += w, + } } let max_idx = weights.iter().enumerate().max_by_key(|&(_, c)| c).map(|(i, _)| i).unwrap_or(0); let ruling = match max_idx { 0 => DisputeRuling::FullRefund, 1 => DisputeRuling::PartialRefund, 2 => DisputeRuling::SellerFavored, 3 => DisputeRuling::Split, _ => DisputeRuling::Dismissed }; diff --git a/contracts/dispute/src/test.rs b/contracts/dispute/src/test.rs new file mode 100644 index 000000000..54e408fc8 --- /dev/null +++ b/contracts/dispute/src/test.rs @@ -0,0 +1,46 @@ +#![no_std] +use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, Vec}; +use crate::{DisputeContract, DisputeContractClient}; +use shared::{DisputeCategory, DisputeRuling, JurorSpecialty}; + +fn setup_env() -> (Env, Address) { + let env = Env::default(); + env.mock_all_auths(); + let caller = Address::generate(&env); + (env, caller) +} + +#[test] +fn test_register_juror() { + let (env, _) = setup_env(); + let contract_id = env.register(DisputeContract, ()); + let client = DisputeContractClient::new(&env, &contract_id); + let juror = Address::generate(&env); + client.register_juror(&juror, &JurorSpecialty::Commerce, &300u32, &10_0000000i128); + let profile = client.get_juror_profile(&juror); + assert!(profile.active); + assert_eq!(profile.reputation_score, 300); +} + +#[test] +fn test_juror_eligibility() { + let (env, _) = setup_env(); + let contract_id = env.register(DisputeContract, ()); + let client = DisputeContractClient::new(&env, &contract_id); + let juror = Address::generate(&env); + client.register_juror(&juror, &JurorSpecialty::General, &250u32, &10_0000000i128); + assert!(client.is_juror_eligible(&juror, &DisputeCategory::NonDelivery)); +} + +#[test] +fn test_open_dispute() { + let (env, caller) = setup_env(); + let contract_id = env.register(DisputeContract, ()); + let client = DisputeContractClient::new(&env, &contract_id); + let filer = Address::generate(&env); + let respondent = Address::generate(&env); + let evidence = BytesN::from_array(&env, &[0u8; 32]); + let jurors = Vec::new(&env); + let id = client.open_dispute(&caller, &1u64, &filer, &respondent, &DisputeCategory::NonDelivery, &evidence, &jurors); + assert_eq!(id, 1); +} diff --git a/contracts/escrow/Cargo.toml b/contracts/escrow/Cargo.toml index 1cb8f371e..83f0afe57 100644 --- a/contracts/escrow/Cargo.toml +++ b/contracts/escrow/Cargo.toml @@ -8,3 +8,4 @@ crate-type = ["cdylib"] [dependencies] soroban-sdk = { workspace = true } +shared = { path = "../shared" } diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index ff6f6acda..e73929501 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -1,128 +1,279 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, BytesN, Vec, token}; - -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum EscrowState { Created, Funded, Delivered, Completed, Disputed, Resolved, Expired, Cancelled, MilestoneActive } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum MilestoneState { Pending, Submitted, Confirmed, Disputed, Released, Expired } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum GroupEscrowState { Collecting, FullyFunded, Delivered, Completed, Disputed, Resolved, Cancelled, Expired } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct Milestone { pub milestone_id: u32, pub description_hash: BytesN<32>, pub amount: i128, pub state: MilestoneState, pub deadline: u64, pub submitted_at: Option, pub confirmed_at: Option } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct GroupParticipant { pub buyer: Address, pub amount: i128, pub funded: bool, pub funded_at: Option, pub refund_percentage: u32 } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct GroupEscrow { pub escrow_id: u64, pub organizer: Address, pub seller: Address, pub token: Address, pub total_amount: i128, pub funded_amount: i128, pub state: GroupEscrowState, pub participants: Vec, pub created_at: u64, pub funding_deadline: u64, pub delivery_deadline: u64, pub auto_release_timeout: u64, pub order_metadata: BytesN<32> } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct EscrowAccount { pub escrow_id: u64, pub buyer: Address, pub seller: Address, pub amount: i128, pub token: Address, pub state: EscrowState, pub created_at: u64, pub delivery_deadline: u64, pub confirmation_deadline: u64, pub auto_release_timeout: u64, pub subscription_id: Option, pub order_metadata: BytesN<32>, pub is_milestone: bool, pub milestones: Vec, pub current_milestone: u32, pub released_amount: i128 } - -const ECTR: Symbol = Symbol::new(&[], "ectr"); const GCTR: Symbol = Symbol::new(&[], "gctr"); -const FEE: Symbol = Symbol::new(&[], "fee"); const COORD: Symbol = Symbol::new(&[], "coord"); -fn ekey(env: &Env, id: u64) -> Symbol { Symbol::new(&[], &format!("e_{}", id).as_str()) } -fn gkey(env: &Env, id: u64) -> Symbol { Symbol::new(&[], &format!("g_{}", id).as_str()) } - -#[contract] pub struct EscrowContract; +use soroban_sdk::{contract, contractimpl, Symbol, token}; +use shared::{EscrowState, MilestoneState, GroupEscrowState, Milestone, GroupParticipant, GroupEscrow, EscrowAccount}; + +#[cfg(test)] +mod test; + +const ECTR: Symbol = Symbol::new(&[], "ectr"); +const GCTR: Symbol = Symbol::new(&[], "gctr"); +const FEE: Symbol = Symbol::new(&[], "fee"); +const COORD: Symbol = Symbol::new(&[], "coord"); +fn ekey(env: &soroban_sdk::Env, id: u64) -> Symbol { Symbol::new(&[], &format!("e_{}", id).as_str()) } +fn gkey(env: &soroban_sdk::Env, id: u64) -> Symbol { Symbol::new(&[], &format!("g_{}", id).as_str()) } + +#[contract] +pub struct EscrowContract; + #[contractimpl] impl EscrowContract { - pub fn initialize(env: Env, coordinator: Address, fee_bps: u32) { - coordinator.require_auth(); if fee_bps > 1000 { panic!("ESC015"); } - env.storage().persistent().set(&COORD, &coordinator); env.storage().persistent().set(&FEE, &fee_bps); - env.storage().persistent().set(&ECTR, &0u64); env.storage().persistent().set(&GCTR, &0u64); + pub fn initialize(env: soroban_sdk::Env, coordinator: soroban_sdk::Address, fee_bps: u32) { + coordinator.require_auth(); + if fee_bps > 1000 { panic!("ESC015: Fee exceed max"); } + env.storage().persistent().set(&COORD, &coordinator); + env.storage().persistent().set(&FEE, &fee_bps); + env.storage().persistent().set(&ECTR, &0u64); + env.storage().persistent().set(&GCTR, &0u64); } - pub fn create_escrow(env: Env, buyer: Address, seller: Address, amount: i128, token: Address, delivery_deadline: u64, auto_release_timeout: u64, order_metadata: BytesN<32>) -> u64 { - buyer.require_auth(); if amount <= 0 { panic!("ESC007"); } if buyer == seller { panic!("ESC009"); } + + pub fn create_escrow( + env: soroban_sdk::Env, buyer: soroban_sdk::Address, seller: soroban_sdk::Address, + amount: i128, token_addr: soroban_sdk::Address, delivery_deadline: u64, + auto_release_timeout: u64, order_metadata: soroban_sdk::BytesN<32>, + ) -> u64 { + buyer.require_auth(); + if amount <= 0 { panic!("ESC007: Amount zero"); } + if buyer == seller { panic!("ESC009: Buyer seller same"); } let id: u64 = env.storage().persistent().get(&ECTR).unwrap_or(0) + 1; - let e = EscrowAccount { escrow_id: id, buyer, seller, amount, token, state: EscrowState::Created, created_at: env.ledger().timestamp(), delivery_deadline, confirmation_deadline: delivery_deadline + auto_release_timeout, auto_release_timeout, subscription_id: None, order_metadata, is_milestone: false, milestones: Vec::new(&env), current_milestone: 0, released_amount: 0 }; - env.storage().persistent().set(&ekey(&env, id), &e); env.storage().persistent().set(&ECTR, &id); id - } - pub fn fund_escrow(env: Env, buyer: Address, escrow_id: u64) { - buyer.require_auth(); let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); - if e.buyer != buyer { panic!("ESC001"); } if e.state != EscrowState::Created { panic!("ESC003"); } - let f: u32 = env.storage().persistent().get(&FEE).unwrap_or(100); - let fee = e.amount * f as i128 / 10000; + let now = env.ledger().timestamp(); + let escrow = EscrowAccount { + escrow_id: id, buyer, seller, amount, token: token_addr, + state: EscrowState::Created, created_at: now, + delivery_deadline, confirmation_deadline: delivery_deadline + auto_release_timeout, + auto_release_timeout, subscription_id: None, order_metadata, + is_milestone: false, milestones: soroban_sdk::Vec::new(&env), + current_milestone: 0, released_amount: 0, + }; + env.storage().persistent().set(&ekey(&env, id), &escrow); + env.storage().persistent().set(&ECTR, &id); + env.events().publish((Symbol::new(&[], "escrow_created"), id), (escrow.buyer.clone(), amount)); + id + } + + pub fn fund_escrow(env: soroban_sdk::Env, buyer: soroban_sdk::Address, escrow_id: u64) { + buyer.require_auth(); + let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); + if e.buyer != buyer { panic!("ESC001: Not buyer"); } + if e.state != EscrowState::Created { panic!("ESC003: Not Created"); } + let fee_bps: u32 = env.storage().persistent().get(&FEE).unwrap_or(100); + let fee = e.amount * fee_bps as i128 / 10000; token::Client::new(&env, &e.token).transfer(&buyer, &env.current_contract_address(), &(e.amount + fee)); - e.state = EscrowState::Funded; env.storage().persistent().set(&ekey(&env, escrow_id), &e); + e.state = EscrowState::Funded; + env.storage().persistent().set(&ekey(&env, escrow_id), &e); + env.events().publish((Symbol::new(&[], "escrow_funded"), escrow_id), (buyer, e.amount)); } - pub fn confirm_delivery(env: Env, seller: Address, escrow_id: u64) { - seller.require_auth(); let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); - if e.seller != seller { panic!("ESC002"); } if e.state != EscrowState::Funded { panic!("ESC004"); } - e.state = EscrowState::Delivered; env.storage().persistent().set(&ekey(&env, escrow_id), &e); + + pub fn confirm_delivery(env: soroban_sdk::Env, seller: soroban_sdk::Address, escrow_id: u64) { + seller.require_auth(); + let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); + if e.seller != seller { panic!("ESC002: Not seller"); } + if e.state != EscrowState::Funded { panic!("ESC004: Not Funded"); } + e.state = EscrowState::Delivered; + env.storage().persistent().set(&ekey(&env, escrow_id), &e); + env.events().publish((Symbol::new(&[], "delivery_confirmed"), escrow_id), seller); } - pub fn confirm_receipt(env: Env, buyer: Address, escrow_id: u64) { - buyer.require_auth(); let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); - if e.buyer != buyer { panic!("ESC001"); } if e.state != EscrowState::Delivered { panic!("ESC005"); } - let f: u32 = env.storage().persistent().get(&FEE).unwrap_or(100); let fee = e.amount * f as i128 / 10000; + + pub fn confirm_receipt(env: soroban_sdk::Env, buyer: soroban_sdk::Address, escrow_id: u64) { + buyer.require_auth(); + let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); + if e.buyer != buyer { panic!("ESC001: Not buyer"); } + if e.state != EscrowState::Delivered { panic!("ESC005: Not Delivered"); } + let fee_bps: u32 = env.storage().persistent().get(&FEE).unwrap_or(100); + let fee = e.amount * fee_bps as i128 / 10000; token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.seller, &(e.amount - fee)); - e.state = EscrowState::Completed; env.storage().persistent().set(&ekey(&env, escrow_id), &e); + e.state = EscrowState::Completed; + env.storage().persistent().set(&ekey(&env, escrow_id), &e); + env.events().publish((Symbol::new(&[], "escrow_completed"), escrow_id), (buyer, e.amount)); } - pub fn auto_release(env: Env, _caller: Address, escrow_id: u64) { + + pub fn auto_release(env: soroban_sdk::Env, _caller: soroban_sdk::Address, escrow_id: u64) { let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); - if e.state != EscrowState::Delivered { panic!("ESC005"); } if env.ledger().timestamp() < e.confirmation_deadline { panic!("ESC013"); } - let f: u32 = env.storage().persistent().get(&FEE).unwrap_or(100); let fee = e.amount * f as i128 / 10000; + if e.state != EscrowState::Delivered { panic!("ESC005: Not Delivered"); } + if env.ledger().timestamp() < e.confirmation_deadline { panic!("ESC013: Timeout not reached"); } + let fee_bps: u32 = env.storage().persistent().get(&FEE).unwrap_or(100); + let fee = e.amount * fee_bps as i128 / 10000; token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.seller, &(e.amount - fee)); - e.state = EscrowState::Completed; env.storage().persistent().set(&ekey(&env, escrow_id), &e); + e.state = EscrowState::Completed; + env.storage().persistent().set(&ekey(&env, escrow_id), &e); + env.events().publish((Symbol::new(&[], "auto_released"), escrow_id), e.seller.clone()); } - pub fn cancel_escrow(env: Env, caller: Address, escrow_id: u64) { - caller.require_auth(); let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); - if e.state != EscrowState::Created && e.state != EscrowState::Funded { panic!("ESC012"); } - if e.state == EscrowState::Funded { token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.buyer, &e.amount); } - e.state = EscrowState::Cancelled; env.storage().persistent().set(&ekey(&env, escrow_id), &e); + + pub fn cancel_escrow(env: soroban_sdk::Env, caller: soroban_sdk::Address, escrow_id: u64) { + caller.require_auth(); + let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); + if e.state != EscrowState::Created && e.state != EscrowState::Funded { panic!("ESC012: Cannot cancel"); } + if e.state == EscrowState::Funded { + token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.buyer, &e.amount); + } + e.state = EscrowState::Cancelled; + env.storage().persistent().set(&ekey(&env, escrow_id), &e); + env.events().publish((Symbol::new(&[], "escrow_cancelled"), escrow_id), caller); } - pub fn expire_escrow(env: Env, _caller: Address, escrow_id: u64) { + + pub fn expire_escrow(env: soroban_sdk::Env, _caller: soroban_sdk::Address, escrow_id: u64) { let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); - if e.state != EscrowState::Funded { panic!("ESC004"); } + if e.state != EscrowState::Funded { panic!("ESC004: Not Funded"); } + if env.ledger().timestamp() < e.delivery_deadline { panic!("ESC008: Deadline not passed"); } token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.buyer, &e.amount); - e.state = EscrowState::Expired; env.storage().persistent().set(&ekey(&env, escrow_id), &e); + e.state = EscrowState::Expired; + env.storage().persistent().set(&ekey(&env, escrow_id), &e); + env.events().publish((Symbol::new(&[], "escrow_expired"), escrow_id), e.buyer.clone()); } - pub fn freeze_for_dispute(env: Env, _caller: Address, escrow_id: u64) { + + pub fn freeze_for_dispute(env: soroban_sdk::Env, _caller: soroban_sdk::Address, escrow_id: u64) { let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); - e.state = EscrowState::Disputed; env.storage().persistent().set(&ekey(&env, escrow_id), &e); + if e.state != EscrowState::Funded && e.state != EscrowState::Delivered { panic!("Invalid state for dispute"); } + e.state = EscrowState::Disputed; + env.storage().persistent().set(&ekey(&env, escrow_id), &e); } - pub fn execute_ruling(env: Env, _caller: Address, escrow_id: u64, buyer_pct: u32) { - if buyer_pct > 100 { panic!("ESC011"); } + + pub fn execute_ruling(env: soroban_sdk::Env, _caller: soroban_sdk::Address, escrow_id: u64, buyer_pct: u32) { + if buyer_pct > 100 { panic!("ESC011: Invalid percentage"); } let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); - if e.state != EscrowState::Disputed { panic!("ESC006"); } - let ba = e.amount * buyer_pct as i128 / 100; let sa = e.amount - ba; - if ba > 0 { token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.buyer, &ba); } - if sa > 0 { token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.seller, &sa); } - e.state = EscrowState::Resolved; env.storage().persistent().set(&ekey(&env, escrow_id), &e); - } - pub fn create_milestone_escrow(env: Env, buyer: Address, seller: Address, total_amount: i128, token: Address, ms_amounts: Vec, ms_deadlines: Vec, ms_descs: Vec>, auto_release_timeout: u64, order_metadata: BytesN<32>) -> u64 { - buyer.require_auth(); if ms_amounts.len() < 2 { panic!("Need 2+ milestones"); } + if e.state != EscrowState::Disputed { panic!("ESC006: Not Disputed"); } + let buyer_amt = e.amount * buyer_pct as i128 / 100; + let seller_amt = e.amount - buyer_amt; + if buyer_amt > 0 { token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.buyer, &buyer_amt); } + if seller_amt > 0 { token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.seller, &seller_amt); } + e.state = EscrowState::Resolved; + env.storage().persistent().set(&ekey(&env, escrow_id), &e); + env.events().publish((Symbol::new(&[], "ruling_executed"), escrow_id), (buyer_pct, buyer_amt, seller_amt)); + } + + // --- v1.1: Milestone Escrow --- + pub fn create_milestone_escrow( + env: soroban_sdk::Env, buyer: soroban_sdk::Address, seller: soroban_sdk::Address, + total_amount: i128, token_addr: soroban_sdk::Address, + ms_amounts: soroban_sdk::Vec, ms_deadlines: soroban_sdk::Vec, + ms_descs: soroban_sdk::Vec>, + auto_release_timeout: u64, order_metadata: soroban_sdk::BytesN<32>, + ) -> u64 { + buyer.require_auth(); + if ms_amounts.len() < 2 { panic!("Need at least 2 milestones"); } let id: u64 = env.storage().persistent().get(&ECTR).unwrap_or(0) + 1; - let mut ms = Vec::new(&env); - for i in 0..ms_amounts.len() { ms.push_back(Milestone { milestone_id: i as u32, description_hash: ms_descs.get(i).unwrap(), amount: ms_amounts.get(i).unwrap(), state: MilestoneState::Pending, deadline: ms_deadlines.get(i).unwrap(), submitted_at: None, confirmed_at: None }); } - let e = EscrowAccount { escrow_id: id, buyer, seller, amount: total_amount, token, state: EscrowState::MilestoneActive, created_at: env.ledger().timestamp(), delivery_deadline: ms_deadlines.get(ms_deadlines.len()-1).unwrap(), confirmation_deadline: env.ledger().timestamp() + auto_release_timeout, auto_release_timeout, subscription_id: None, order_metadata, is_milestone: true, milestones: ms, current_milestone: 0, released_amount: 0 }; - env.storage().persistent().set(&ekey(&env, id), &e); env.storage().persistent().set(&ECTR, &id); id - } - pub fn submit_milestone(env: Env, seller: Address, escrow_id: u64, ms_id: u32) { - seller.require_auth(); let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); - let mut m = e.milestones.get(ms_id as usize).unwrap(); m.state = MilestoneState::Submitted; m.submitted_at = Some(env.ledger().timestamp()); - e.milestones.set(ms_id as usize, m); env.storage().persistent().set(&ekey(&env, escrow_id), &e); - } - pub fn confirm_milestone(env: Env, buyer: Address, escrow_id: u64, ms_id: u32) { - buyer.require_auth(); let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); - let mut m = e.milestones.get(ms_id as usize).unwrap(); - let f: u32 = env.storage().persistent().get(&FEE).unwrap_or(100); let fee = m.amount * f as i128 / 10000; - token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.seller, &(m.amount - fee)); - m.state = MilestoneState::Released; m.confirmed_at = Some(env.ledger().timestamp()); e.milestones.set(ms_id as usize, m); - e.released_amount += m.amount; e.current_milestone = ms_id + 1; + let mut milestones = soroban_sdk::Vec::new(&env); + for i in 0..ms_amounts.len() { + milestones.push_back(Milestone { + milestone_id: i as u32, + description_hash: ms_descs.get(i).unwrap(), + amount: ms_amounts.get(i).unwrap(), + state: MilestoneState::Pending, + deadline: ms_deadlines.get(i).unwrap(), + submitted_at: None, confirmed_at: None, + }); + } + let now = env.ledger().timestamp(); + let escrow = EscrowAccount { + escrow_id: id, buyer, seller, amount: total_amount, token: token_addr, + state: EscrowState::MilestoneActive, created_at: now, + delivery_deadline: ms_deadlines.get(ms_deadlines.len() - 1).unwrap(), + confirmation_deadline: now + auto_release_timeout, + auto_release_timeout, subscription_id: None, order_metadata, + is_milestone: true, milestones, current_milestone: 0, released_amount: 0, + }; + env.storage().persistent().set(&ekey(&env, id), &escrow); + env.storage().persistent().set(&ECTR, &id); + env.events().publish((Symbol::new(&[], "milestone_escrow_created"), id), total_amount); + id + } + + pub fn submit_milestone(env: soroban_sdk::Env, seller: soroban_sdk::Address, escrow_id: u64, ms_id: u32) { + seller.require_auth(); + let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); + if e.seller != seller { panic!("ESC002: Not seller"); } + let mut ms = e.milestones.get(ms_id as usize).unwrap(); + if ms.state != MilestoneState::Pending { panic!("Milestone not pending"); } + ms.state = MilestoneState::Submitted; + ms.submitted_at = Some(env.ledger().timestamp()); + e.milestones.set(ms_id as usize, ms); + env.storage().persistent().set(&ekey(&env, escrow_id), &e); + env.events().publish((Symbol::new(&[], "milestone_submitted"), escrow_id), ms_id); + } + + pub fn confirm_milestone(env: soroban_sdk::Env, buyer: soroban_sdk::Address, escrow_id: u64, ms_id: u32) { + buyer.require_auth(); + let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); + if e.buyer != buyer { panic!("ESC001: Not buyer"); } + let mut ms = e.milestones.get(ms_id as usize).unwrap(); + if ms.state != MilestoneState::Submitted { panic!("Milestone not submitted"); } + let fee_bps: u32 = env.storage().persistent().get(&FEE).unwrap_or(100); + let fee = ms.amount * fee_bps as i128 / 10000; + token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.seller, &(ms.amount - fee)); + ms.state = MilestoneState::Released; + ms.confirmed_at = Some(env.ledger().timestamp()); + e.milestones.set(ms_id as usize, ms); + e.released_amount += ms.amount; + e.current_milestone = ms_id + 1; if e.released_amount >= e.amount { e.state = EscrowState::Completed; } env.storage().persistent().set(&ekey(&env, escrow_id), &e); + env.events().publish((Symbol::new(&[], "milestone_confirmed"), escrow_id), ms_id); + } + + pub fn expire_milestone(env: soroban_sdk::Env, _caller: soroban_sdk::Address, escrow_id: u64, ms_id: u32) { + let mut e: EscrowAccount = env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap(); + let mut ms = e.milestones.get(ms_id as usize).unwrap(); + if ms.state != MilestoneState::Pending && ms.state != MilestoneState::Submitted { panic!("Milestone not expirable"); } + if env.ledger().timestamp() < ms.deadline { panic!("Deadline not passed"); } + ms.state = MilestoneState::Expired; + e.milestones.set(ms_id as usize, ms); + let remaining = e.amount - e.released_amount - ms.amount; + if remaining > 0 { token::Client::new(&env, &e.token).transfer(&env.current_contract_address(), &e.buyer, &remaining); } + e.state = EscrowState::Resolved; + env.storage().persistent().set(&ekey(&env, escrow_id), &e); + env.events().publish((Symbol::new(&[], "milestone_expired"), escrow_id), ms_id); } - pub fn create_group_escrow(env: Env, organizer: Address, seller: Address, token: Address, total_amount: i128, participants: Vec, funding_deadline: u64, delivery_deadline: u64, auto_release_timeout: u64, order_metadata: BytesN<32>) -> u64 { - organizer.require_auth(); let id: u64 = env.storage().persistent().get(&GCTR).unwrap_or(0) + 1; - let g = GroupEscrow { escrow_id: id, organizer, seller, token, total_amount, funded_amount: 0, state: GroupEscrowState::Collecting, participants, created_at: env.ledger().timestamp(), funding_deadline, delivery_deadline, auto_release_timeout, order_metadata }; - env.storage().persistent().set(&gkey(&env, id), &g); env.storage().persistent().set(&GCTR, &id); id + + // --- v1.1: Group Escrow --- + pub fn create_group_escrow( + env: soroban_sdk::Env, organizer: soroban_sdk::Address, seller: soroban_sdk::Address, + token_addr: soroban_sdk::Address, total_amount: i128, + participants: soroban_sdk::Vec, + funding_deadline: u64, delivery_deadline: u64, auto_release_timeout: u64, + order_metadata: soroban_sdk::BytesN<32>, + ) -> u64 { + organizer.require_auth(); + if total_amount <= 0 { panic!("ESC007: Amount zero"); } + let id: u64 = env.storage().persistent().get(&GCTR).unwrap_or(0) + 1; + let group = GroupEscrow { + escrow_id: id, organizer, seller, token: token_addr, + total_amount, funded_amount: 0, state: GroupEscrowState::Collecting, + participants, created_at: env.ledger().timestamp(), + funding_deadline, delivery_deadline, auto_release_timeout, order_metadata, + }; + env.storage().persistent().set(&gkey(&env, id), &group); + env.storage().persistent().set(&GCTR, &id); + env.events().publish((Symbol::new(&[], "group_escrow_created"), id), total_amount); + id } - pub fn fund_group_escrow(env: Env, buyer: Address, escrow_id: u64) { - buyer.require_auth(); let mut g: GroupEscrow = env.storage().persistent().get(&gkey(&env, escrow_id)).unwrap(); - for i in 0..g.participants.len() { let mut p = g.participants.get(i).unwrap(); - if p.buyer == buyer && !p.funded { token::Client::new(&env, &g.token).transfer(&buyer, &env.current_contract_address(), &p.amount); - p.funded = true; p.funded_at = Some(env.ledger().timestamp()); g.funded_amount += p.amount; g.participants.set(i, p); break; } } + + pub fn fund_group_escrow(env: soroban_sdk::Env, buyer: soroban_sdk::Address, escrow_id: u64) { + buyer.require_auth(); + let mut g: GroupEscrow = env.storage().persistent().get(&gkey(&env, escrow_id)).unwrap(); + if g.state != GroupEscrowState::Collecting { panic!("Not in collecting state"); } + let mut found = false; + for i in 0..g.participants.len() { + let mut p = g.participants.get(i).unwrap(); + if p.buyer == buyer && !p.funded { + token::Client::new(&env, &g.token).transfer(&buyer, &env.current_contract_address(), &p.amount); + p.funded = true; + p.funded_at = Some(env.ledger().timestamp()); + g.funded_amount += p.amount; + g.participants.set(i, p); + found = true; + break; + } + } + if !found { panic!("Not a participant or already funded"); } if g.funded_amount >= g.total_amount { g.state = GroupEscrowState::FullyFunded; } env.storage().persistent().set(&gkey(&env, escrow_id), &g); + env.events().publish((Symbol::new(&[], "group_funded"), escrow_id), (buyer, g.funded_amount)); + } + + pub fn get_escrow(env: soroban_sdk::Env, escrow_id: u64) -> EscrowAccount { + env.storage().persistent().get(&ekey(&env, escrow_id)).unwrap() + } + + pub fn get_group_escrow(env: soroban_sdk::Env, escrow_id: u64) -> GroupEscrow { + env.storage().persistent().get(&gkey(&env, escrow_id)).unwrap() } - pub fn get_escrow(env: Env, id: u64) -> EscrowAccount { env.storage().persistent().get(&ekey(&env, id)).unwrap() } - pub fn get_group_escrow(env: Env, id: u64) -> GroupEscrow { env.storage().persistent().get(&gkey(&env, id)).unwrap() } } diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs new file mode 100644 index 000000000..520ce94e9 --- /dev/null +++ b/contracts/escrow/src/test.rs @@ -0,0 +1,59 @@ +#![no_std] +use soroban_sdk::{testutils::{Address as _, Ledger as _}, Address, BytesN, Env, Vec}; +use crate::{EscrowContract, EscrowContractClient}; + +fn setup_env() -> (Env, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let coordinator = Address::generate(&env); + let token_id = Address::generate(&env); + (env, coordinator, token_id) +} + +#[test] +fn test_create_escrow() { + let (env, coordinator, token_id) = setup_env(); + let contract_id = env.register(EscrowContract, ()); + let client = EscrowContractClient::new(&env, &contract_id); + let buyer = Address::generate(&env); + let seller = Address::generate(&env); + let metadata = BytesN::from_array(&env, &[0u8; 32]); + + client.initialize(&coordinator, &100u32); + + let id = client.create_escrow( + &buyer, &seller, &1000i128, &token_id, &env.ledger().timestamp() + 86400, + &86400u64, &metadata, + ); + assert_eq!(id, 1); +} + +#[test] +fn test_escrow_lifecycle() { + let (env, coordinator, token_id) = setup_env(); + let contract_id = env.register(EscrowContract, ()); + let client = EscrowContractClient::new(&env, &contract_id); + let buyer = Address::generate(&env); + let seller = Address::generate(&env); + let metadata = BytesN::from_array(&env, &[0u8; 32]); + + client.initialize(&coordinator, &100u32); + + let id = client.create_escrow( + &buyer, &seller, &1000i128, &token_id, &env.ledger().timestamp() + 86400, + &86400u64, &metadata, + ); + + let escrow = client.get_escrow(&id); + assert!(escrow.state == shared::EscrowState::Created); +} + +#[test] +fn test_initialize_fee_exceeds_max() { + let (env, coordinator, _) = setup_env(); + let contract_id = env.register(EscrowContract, ()); + let client = EscrowContractClient::new(&env, &contract_id); + + let result = client.try_initialize(&coordinator, &1001u32); + assert!(result.is_err()); +} diff --git a/contracts/loyalty/Cargo.toml b/contracts/loyalty/Cargo.toml index 0f7b9f043..81e158c7c 100644 --- a/contracts/loyalty/Cargo.toml +++ b/contracts/loyalty/Cargo.toml @@ -8,3 +8,4 @@ crate-type = ["cdylib"] [dependencies] soroban-sdk = { workspace = true } +shared = { path = "../shared" } diff --git a/contracts/loyalty/src/lib.rs b/contracts/loyalty/src/lib.rs index 0af23617e..3138856e8 100644 --- a/contracts/loyalty/src/lib.rs +++ b/contracts/loyalty/src/lib.rs @@ -1,40 +1,50 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, BytesN}; +use soroban_sdk::{contract, contractimpl, Symbol, Address, BytesN, Env}; +use shared::{LoyaltyTier, RewardType, LoyaltyProfile}; -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum LoyaltyTier { Starter, Regular, Trusted, Elite, Legendary } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum RewardType { FeeWaiver, JurorPriority, MerchantSpotlight, ReputationBoost, GovernanceVote } +#[cfg(test)] +mod test; -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct LoyaltyProfile { pub pioneer: Address, pub points: u32, pub tier: LoyaltyTier, pub lifetime_points: u32, pub redeemable_points: u32, pub last_activity: u64, pub referral_code: BytesN<32>, pub referral_count: u32, pub activity_streak: u32 } +fn loyalty_key(_env: &Env, _addr: &Address) -> Symbol { Symbol::new(&[], "loyalty") } -fn loyalty_key(env: &Env, addr: &Address) -> Symbol { Symbol::new(&[], "loyalty") } fn points_to_tier(pts: u32) -> LoyaltyTier { if pts >= 10000 { LoyaltyTier::Legendary } else if pts >= 2000 { LoyaltyTier::Elite } else if pts >= 500 { LoyaltyTier::Trusted } else if pts >= 100 { LoyaltyTier::Regular } else { LoyaltyTier::Starter } } -#[contract] pub struct LoyaltyContract; +#[contract] +pub struct LoyaltyContract; + #[contractimpl] impl LoyaltyContract { pub fn create_profile(env: Env, pioneer: Address) -> LoyaltyProfile { pioneer.require_auth(); - let p = LoyaltyProfile { pioneer: pioneer.clone(), points: 0, tier: LoyaltyTier::Starter, lifetime_points: 0, redeemable_points: 0, last_activity: env.ledger().timestamp(), referral_code: BytesN::from_array(&env, &[0;32]), referral_count: 0, activity_streak: 0 }; - env.storage().persistent().set(&loyalty_key(&env, &pioneer), &p); p + let p = LoyaltyProfile { + pioneer: pioneer.clone(), points: 0, tier: LoyaltyTier::Starter, + lifetime_points: 0, redeemable_points: 0, last_activity: env.ledger().timestamp(), + referral_code: BytesN::from_array(&env, &[0;32]), referral_count: 0, activity_streak: 0, + }; + env.storage().persistent().set(&loyalty_key(&env, &pioneer), &p); + p } + pub fn earn_points(env: Env, _caller: Address, pioneer: Address, _action: Symbol, amount: u32) { let mut p: LoyaltyProfile = env.storage().persistent().get(&loyalty_key(&env, &pioneer)).unwrap(); p.points += amount; p.lifetime_points += amount; p.redeemable_points += amount; p.tier = points_to_tier(p.lifetime_points); p.last_activity = env.ledger().timestamp(); env.storage().persistent().set(&loyalty_key(&env, &pioneer), &p); } + pub fn redeem_reward(env: Env, pioneer: Address, _reward_type: RewardType, amount: u32) { pioneer.require_auth(); let mut p: LoyaltyProfile = env.storage().persistent().get(&loyalty_key(&env, &pioneer)).unwrap(); if p.redeemable_points < amount { panic!("Insufficient points"); } - p.redeemable_points -= amount; env.storage().persistent().set(&loyalty_key(&env, &pioneer), &p); + p.redeemable_points -= amount; + env.storage().persistent().set(&loyalty_key(&env, &pioneer), &p); + } + + pub fn get_profile(env: Env, pioneer: Address) -> LoyaltyProfile { + env.storage().persistent().get(&loyalty_key(&env, &pioneer)).unwrap() } - pub fn get_profile(env: Env, pioneer: Address) -> LoyaltyProfile { env.storage().persistent().get(&loyalty_key(&env, &pioneer)).unwrap() } } diff --git a/contracts/loyalty/src/test.rs b/contracts/loyalty/src/test.rs new file mode 100644 index 000000000..b37e4dbf5 --- /dev/null +++ b/contracts/loyalty/src/test.rs @@ -0,0 +1,35 @@ +#![no_std] +use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, Symbol}; +use crate::{LoyaltyContract, LoyaltyContractClient}; +use shared::LoyaltyTier; + +fn setup_env() -> (Env, Address) { + let env = Env::default(); + env.mock_all_auths(); + let caller = Address::generate(&env); + (env, caller) +} + +#[test] +fn test_create_profile() { + let (env, _) = setup_env(); + let contract_id = env.register(LoyaltyContract, ()); + let client = LoyaltyContractClient::new(&env, &contract_id); + let pioneer = Address::generate(&env); + let profile = client.create_profile(&pioneer); + assert!(profile.tier == LoyaltyTier::Starter); + assert_eq!(profile.points, 0); +} + +#[test] +fn test_earn_points() { + let (env, caller) = setup_env(); + let contract_id = env.register(LoyaltyContract, ()); + let client = LoyaltyContractClient::new(&env, &contract_id); + let pioneer = Address::generate(&env); + client.create_profile(&pioneer); + client.earn_points(&caller, &pioneer, &Symbol::new(&env, "escrow"), &15u32); + let profile = client.get_profile(&pioneer); + assert_eq!(profile.points, 15); + assert_eq!(profile.lifetime_points, 15); +} diff --git a/contracts/merchant/Cargo.toml b/contracts/merchant/Cargo.toml index e76bc948b..d6d0f68e7 100644 --- a/contracts/merchant/Cargo.toml +++ b/contracts/merchant/Cargo.toml @@ -8,3 +8,4 @@ crate-type = ["cdylib"] [dependencies] soroban-sdk = { workspace = true } +shared = { path = "../shared" } diff --git a/contracts/merchant/src/lib.rs b/contracts/merchant/src/lib.rs index 524cdd28a..7f4befcd7 100644 --- a/contracts/merchant/src/lib.rs +++ b/contracts/merchant/src/lib.rs @@ -1,39 +1,50 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, BytesN}; +use soroban_sdk::{contract, contractimpl, Symbol, Address, BytesN, Env}; +use shared::{VerificationLevel, VerificationStatus, MerchantCategory, MerchantProfile}; -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum VerificationLevel { None, Basic, Standard, Premium } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum VerificationStatus { NotApplied, Pending, UnderReview, InfoRequested, Approved, Suspended, Revoked, Expired } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum MerchantCategory { DigitalGoods, PhysicalGoods, Services, FoodAndBeverage, Entertainment, Education, HealthAndWellness, ProfessionalServices, Retail, Other } +#[cfg(test)] +mod test; -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct MerchantProfile { pub merchant: Address, pub level: VerificationLevel, pub business_name_hash: BytesN<32>, pub category: MerchantCategory, pub status: VerificationStatus, pub jurisdiction: BytesN<2>, pub total_volume: i128, pub total_orders: u32, pub avg_rating: u32, pub verified_at: Option, pub expires_at: Option, pub location_count: u32, pub metadata_uri: BytesN<32> } +fn merchant_key(_env: &Env, _addr: &Address) -> Symbol { Symbol::new(&[], "merchant") } -fn merchant_key(env: &Env, addr: &Address) -> Symbol { Symbol::new(&[], "merchant") } +#[contract] +pub struct MerchantContract; -#[contract] pub struct MerchantContract; #[contractimpl] impl MerchantContract { pub fn apply_verification(env: Env, merchant: Address, business_name_hash: BytesN<32>, category: MerchantCategory, jurisdiction: BytesN<2>, metadata_uri: BytesN<32>) { merchant.require_auth(); - let p = MerchantProfile { merchant: merchant.clone(), level: VerificationLevel::None, business_name_hash, category, status: VerificationStatus::Pending, jurisdiction, total_volume: 0, total_orders: 0, avg_rating: 0, verified_at: None, expires_at: None, location_count: 0, metadata_uri }; + let p = MerchantProfile { + merchant: merchant.clone(), level: VerificationLevel::None, business_name_hash, category, + status: VerificationStatus::Pending, jurisdiction, total_volume: 0, total_orders: 0, + avg_rating: 0, verified_at: None, expires_at: None, location_count: 0, metadata_uri, + }; env.storage().persistent().set(&merchant_key(&env, &merchant), &p); + env.events().publish((Symbol::new(&[], "merchant_applied"), merchant), category); } + pub fn approve_verification(env: Env, _caller: Address, merchant: Address, level: VerificationLevel) { let mut p: MerchantProfile = env.storage().persistent().get(&merchant_key(&env, &merchant)).unwrap(); - p.level = level; p.status = VerificationStatus::Approved; p.verified_at = Some(env.ledger().timestamp()); + p.level = level; p.status = VerificationStatus::Approved; + p.verified_at = Some(env.ledger().timestamp()); p.expires_at = Some(env.ledger().timestamp() + 31536000); env.storage().persistent().set(&merchant_key(&env, &merchant), &p); + env.events().publish((Symbol::new(&[], "merchant_approved"), merchant), level); } + pub fn suspend_merchant(env: Env, _caller: Address, merchant: Address, _reason_hash: BytesN<32>) { let mut p: MerchantProfile = env.storage().persistent().get(&merchant_key(&env, &merchant)).unwrap(); - p.status = VerificationStatus::Suspended; env.storage().persistent().set(&merchant_key(&env, &merchant), &p); + p.status = VerificationStatus::Suspended; + env.storage().persistent().set(&merchant_key(&env, &merchant), &p); } + pub fn revoke_verification(env: Env, _caller: Address, merchant: Address, _reason_hash: BytesN<32>) { let mut p: MerchantProfile = env.storage().persistent().get(&merchant_key(&env, &merchant)).unwrap(); - p.status = VerificationStatus::Revoked; env.storage().persistent().set(&merchant_key(&env, &merchant), &p); + p.status = VerificationStatus::Revoked; + env.storage().persistent().set(&merchant_key(&env, &merchant), &p); + } + + pub fn get_merchant(env: Env, merchant: Address) -> MerchantProfile { + env.storage().persistent().get(&merchant_key(&env, &merchant)).unwrap() } - pub fn get_merchant(env: Env, merchant: Address) -> MerchantProfile { env.storage().persistent().get(&merchant_key(&env, &merchant)).unwrap() } } diff --git a/contracts/merchant/src/test.rs b/contracts/merchant/src/test.rs new file mode 100644 index 000000000..7bbe913df --- /dev/null +++ b/contracts/merchant/src/test.rs @@ -0,0 +1,40 @@ +#![no_std] +use soroban_sdk::{testutils::Address as _, Address, BytesN, Env}; +use crate::{MerchantContract, MerchantContractClient}; +use shared::{MerchantCategory, VerificationLevel}; + +fn setup_env() -> (Env, Address) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + (env, admin) +} + +#[test] +fn test_apply_verification() { + let (env, _) = setup_env(); + let contract_id = env.register(MerchantContract, ()); + let client = MerchantContractClient::new(&env, &contract_id); + let merchant = Address::generate(&env); + let name_hash = BytesN::from_array(&env, &[0u8; 32]); + let jurisdiction = BytesN::from_array(&env, &[0u8; 2]); + let metadata = BytesN::from_array(&env, &[0u8; 32]); + client.apply_verification(&merchant, &name_hash, &MerchantCategory::DigitalGoods, &jurisdiction, &metadata); + let profile = client.get_merchant(&merchant); + assert!(profile.status == shared::VerificationStatus::Pending); +} + +#[test] +fn test_approve_verification() { + let (env, admin) = setup_env(); + let contract_id = env.register(MerchantContract, ()); + let client = MerchantContractClient::new(&env, &contract_id); + let merchant = Address::generate(&env); + let name_hash = BytesN::from_array(&env, &[0u8; 32]); + let jurisdiction = BytesN::from_array(&env, &[0u8; 2]); + let metadata = BytesN::from_array(&env, &[0u8; 32]); + client.apply_verification(&merchant, &name_hash, &MerchantCategory::Services, &jurisdiction, &metadata); + client.approve_verification(&admin, &merchant, &VerificationLevel::Standard); + let profile = client.get_merchant(&merchant); + assert!(profile.status == shared::VerificationStatus::Approved); +} diff --git a/contracts/reputation/Cargo.toml b/contracts/reputation/Cargo.toml index d70c0f965..a62022293 100644 --- a/contracts/reputation/Cargo.toml +++ b/contracts/reputation/Cargo.toml @@ -8,3 +8,4 @@ crate-type = ["cdylib"] [dependencies] soroban-sdk = { workspace = true } +shared = { path = "../shared" } diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index e166f47d4..a1f160ecc 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -1,27 +1,24 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, BytesN, Vec}; - -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum ReputationTier { Bronze, Silver, Gold, Platinum, Diamond } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum SoulboundBadge { FirstTrade, TrustedBuyer, TrustedSeller, VerifiedMerchant, JurorVeteran, CommunityGuardian, EarlyAdopter, PlatinumTrader, DiamondElite, LoyaltyChampion } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum AttestationType { IdentityVouch, CommerceVouch, SkillVouch, CommunityVouch } - -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct BadgeOwnership { pub pioneer: Address, pub badge: SoulboundBadge, pub awarded_at: u64, pub award_reason: BytesN<32>, pub revoked: bool } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct Attestation { pub attestation_id: u64, pub attester: Address, pub attested: Address, pub attestation_type: AttestationType, pub attester_reputation: u32, pub weight: u32, pub created_at: u64, pub expires_at: u64, pub active: bool } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct SybilProfile { pub pioneer: Address, pub unique_counterparties: u32, pub total_transactions: u32, pub reciprocal_ratio: u32, pub avg_tx_interval: u64, pub cluster_flag: bool, pub last_analysis: u64, pub sybil_score: u32 } -#[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct ReputationProfile { pub pioneer: Address, pub score: u32, pub tier: ReputationTier, pub total_escrows: u32, pub completed_escrows: u32, pub expired_escrows: u32, pub disputes_as_buyer: u32, pub disputes_as_seller: u32, pub rulings_in_favor: u32, pub rulings_against: u32, pub is_verified_merchant: bool, pub created_at: u64, pub last_active: u64, pub history_root: BytesN<32>, pub score_nonce: u32, pub badge_count: u32, pub attestation_score: u32, pub sybil_score: u32, pub unique_counterparties: u32 } +use soroban_sdk::{contract, contractimpl, Symbol, Address, BytesN, Env, Vec}; +use shared::{ReputationTier, SoulboundBadge, AttestationType, BadgeOwnership, Attestation, SybilProfile, ReputationProfile}; + +#[cfg(test)] +mod test; const ATTEST_CTR: Symbol = Symbol::new(&[], "att_ctr"); -fn rep_key(env: &Env, addr: &Address) -> Symbol { Symbol::new(&[], "rep") } -fn badge_key(env: &Env, addr: &Address, badge: &SoulboundBadge) -> Symbol { Symbol::new(&[], &format!("badge_{}", badge.discriminant()).as_str()) } -fn attest_key(env: &Env, id: u64) -> Symbol { Symbol::new(&[], &format!("att_{}", id).as_str()) } -fn sybil_key(env: &Env, addr: &Address) -> Symbol { Symbol::new(&[], "sybil") } +fn rep_key(_env: &Env, _addr: &Address) -> Symbol { Symbol::new(&[], "rep") } +fn badge_key(_env: &Env, _addr: &Address, badge: &SoulboundBadge) -> Symbol { + let disc = match badge { + SoulboundBadge::FirstTrade => 0u32, SoulboundBadge::TrustedBuyer => 1, + SoulboundBadge::TrustedSeller => 2, SoulboundBadge::VerifiedMerchant => 3, + SoulboundBadge::JurorVeteran => 4, SoulboundBadge::CommunityGuardian => 5, + SoulboundBadge::EarlyAdopter => 6, SoulboundBadge::PlatinumTrader => 7, + SoulboundBadge::DiamondElite => 8, SoulboundBadge::LoyaltyChampion => 9, + }; + Symbol::new(&[], &format!("bdg_{}", disc).as_str()) +} +fn attest_key(_env: &Env, id: u64) -> Symbol { Symbol::new(&[], &format!("att_{}", id).as_str()) } +fn sybil_key(_env: &Env, _addr: &Address) -> Symbol { Symbol::new(&[], "sybil") } fn score_to_tier(score: u32) -> ReputationTier { if score >= 900 { ReputationTier::Diamond } else if score >= 700 { ReputationTier::Platinum } @@ -29,23 +26,35 @@ fn score_to_tier(score: u32) -> ReputationTier { else { ReputationTier::Bronze } } -#[contract] pub struct ReputationContract; +#[contract] +pub struct ReputationContract; + #[contractimpl] impl ReputationContract { pub fn create_profile(env: Env, pioneer: Address) -> ReputationProfile { pioneer.require_auth(); - let p = ReputationProfile { pioneer: pioneer.clone(), score: 200, tier: ReputationTier::Silver, total_escrows: 0, completed_escrows: 0, expired_escrows: 0, disputes_as_buyer: 0, disputes_as_seller: 0, rulings_in_favor: 0, rulings_against: 0, is_verified_merchant: false, created_at: env.ledger().timestamp(), last_active: env.ledger().timestamp(), history_root: BytesN::from_array(&env, &[0;32]), score_nonce: 0, badge_count: 0, attestation_score: 0, sybil_score: 0, unique_counterparties: 0 }; + let p = ReputationProfile { + pioneer: pioneer.clone(), score: 200, tier: ReputationTier::Silver, + total_escrows: 0, completed_escrows: 0, expired_escrows: 0, + disputes_as_buyer: 0, disputes_as_seller: 0, rulings_in_favor: 0, rulings_against: 0, + is_verified_merchant: false, created_at: env.ledger().timestamp(), + last_active: env.ledger().timestamp(), history_root: BytesN::from_array(&env, &[0;32]), + score_nonce: 0, badge_count: 0, attestation_score: 0, sybil_score: 0, unique_counterparties: 0, + }; env.storage().persistent().set(&rep_key(&env, &pioneer), &p); p } + pub fn record_escrow_completion(env: Env, _caller: Address, pioneer: Address, as_seller: bool) -> u32 { let mut p: ReputationProfile = env.storage().persistent().get(&rep_key(&env, &pioneer)).unwrap(); p.total_escrows += 1; p.completed_escrows += 1; let bonus: u32 = if as_seller { 5 } else { 3 }; p.score = (p.score + bonus).min(1000); p.tier = score_to_tier(p.score); p.last_active = env.ledger().timestamp(); p.score_nonce += 1; - env.storage().persistent().set(&rep_key(&env, &pioneer), &p); p.score + env.storage().persistent().set(&rep_key(&env, &pioneer), &p); + p.score } + pub fn record_escrow_expiry(env: Env, _caller: Address, seller: Address) -> u32 { let mut p: ReputationProfile = env.storage().persistent().get(&rep_key(&env, &seller)).unwrap(); p.total_escrows += 1; p.expired_escrows += 1; @@ -53,6 +62,7 @@ impl ReputationContract { p.last_active = env.ledger().timestamp(); env.storage().persistent().set(&rep_key(&env, &seller), &p); p.score } + pub fn record_dispute_ruling(env: Env, _caller: Address, pioneer: Address, ruling_in_favor: bool, _as_seller: bool) -> u32 { let mut p: ReputationProfile = env.storage().persistent().get(&rep_key(&env, &pioneer)).unwrap(); if ruling_in_favor { p.rulings_in_favor += 1; p.score = (p.score + 10).min(1000); } @@ -60,6 +70,7 @@ impl ReputationContract { p.tier = score_to_tier(p.score); p.last_active = env.ledger().timestamp(); env.storage().persistent().set(&rep_key(&env, &pioneer), &p); p.score } + pub fn set_merchant_status(env: Env, _caller: Address, pioneer: Address, is_verified: bool) { let mut p: ReputationProfile = env.storage().persistent().get(&rep_key(&env, &pioneer)).unwrap(); p.is_verified_merchant = is_verified; @@ -67,7 +78,10 @@ impl ReputationContract { p.tier = score_to_tier(p.score); env.storage().persistent().set(&rep_key(&env, &pioneer), &p); } - pub fn get_profile(env: Env, pioneer: Address) -> ReputationProfile { env.storage().persistent().get(&rep_key(&env, &pioneer)).unwrap() } + + pub fn get_profile(env: Env, pioneer: Address) -> ReputationProfile { + env.storage().persistent().get(&rep_key(&env, &pioneer)).unwrap() + } pub fn get_score(env: Env, pioneer: Address) -> u32 { Self::get_profile(env, pioneer).score } pub fn get_tier(env: Env, pioneer: Address) -> ReputationTier { Self::get_profile(env, pioneer).tier } pub fn verify_threshold(env: Env, pioneer: Address, minimum_score: u32) -> bool { Self::get_score(env, pioneer) >= minimum_score } @@ -79,16 +93,22 @@ impl ReputationContract { let mut p: ReputationProfile = env.storage().persistent().get(&rep_key(&env, &pioneer)).unwrap(); p.badge_count += 1; p.score = (p.score + 2).min(1000); p.tier = score_to_tier(p.score); env.storage().persistent().set(&rep_key(&env, &pioneer), &p); + env.events().publish((Symbol::new(&[], "badge_awarded"), pioneer.clone()), badge); } + pub fn revoke_badge(env: Env, _caller: Address, pioneer: Address, badge: SoulboundBadge) { let mut b: BadgeOwnership = env.storage().persistent().get(&badge_key(&env, &pioneer, &badge)).unwrap(); b.revoked = true; env.storage().persistent().set(&badge_key(&env, &pioneer, &badge), &b); let mut p: ReputationProfile = env.storage().persistent().get(&rep_key(&env, &pioneer)).unwrap(); p.score = p.score.saturating_sub(10); p.tier = score_to_tier(p.score); env.storage().persistent().set(&rep_key(&env, &pioneer), &p); + env.events().publish((Symbol::new(&[], "badge_revoked"), pioneer.clone()), badge); } + pub fn has_badge(env: Env, pioneer: Address, badge: SoulboundBadge) -> bool { - match env.storage().persistent().get(&badge_key(&env, &pioneer, &badge)) { Some(b: BadgeOwnership) => !b.revoked, None => false } + match env.storage().persistent().get(&badge_key(&env, &pioneer, &badge)) { + Some(b: BadgeOwnership) => !b.revoked, None => false, + } } // v1.1: Attestations @@ -99,13 +119,15 @@ impl ReputationContract { if attester == attested { panic!("REP005: Cannot self-attest"); } let id: u64 = env.storage().persistent().get(&ATTEST_CTR).unwrap_or(0) + 1; let w: u32 = match ap.tier { ReputationTier::Silver => 2, ReputationTier::Gold => 3, ReputationTier::Platinum => 4, ReputationTier::Diamond => 5, _ => 1 }; - let a = Attestation { attestation_id: id, attester, attested, attestation_type, attester_reputation: ap.score, weight: w, created_at: env.ledger().timestamp(), expires_at: env.ledger().timestamp() + 15552000, active: true }; + let a = Attestation { attestation_id: id, attester, attested: attested.clone(), attestation_type, attester_reputation: ap.score, weight: w, created_at: env.ledger().timestamp(), expires_at: env.ledger().timestamp() + 15552000, active: true }; env.storage().persistent().set(&attest_key(&env, id), &a); env.storage().persistent().set(&ATTEST_CTR, &id); let mut tp: ReputationProfile = env.storage().persistent().get(&rep_key(&env, &attested)).unwrap(); tp.attestation_score = (tp.attestation_score + w).min(100); if tp.attestation_score >= 20 { tp.score = (tp.score + 5).min(1000); tp.tier = score_to_tier(tp.score); } - env.storage().persistent().set(&rep_key(&env, &attested), &tp); id + env.storage().persistent().set(&rep_key(&env, &attested), &tp); + id } + pub fn revoke_attestation(env: Env, _caller: Address, attestation_id: u64) { let mut a: Attestation = env.storage().persistent().get(&attest_key(&env, attestation_id)).unwrap(); if !a.active { panic!("REP006: Already revoked"); } @@ -129,12 +151,17 @@ impl ReputationContract { if sp.sybil_score > 5000 { p.score = p.score.saturating_sub(5); p.tier = score_to_tier(p.score); } env.storage().persistent().set(&rep_key(&env, &pioneer), &p); } - pub fn get_sybil_profile(env: Env, pioneer: Address) -> SybilProfile { env.storage().persistent().get(&sybil_key(&env, &pioneer)).unwrap_or(SybilProfile { pioneer, unique_counterparties: 0, total_transactions: 0, reciprocal_ratio: 0, avg_tx_interval: 0, cluster_flag: false, last_analysis: 0, sybil_score: 0 }) } + + pub fn get_sybil_profile(env: Env, pioneer: Address) -> SybilProfile { + env.storage().persistent().get(&sybil_key(&env, &pioneer)).unwrap_or(SybilProfile { pioneer, unique_counterparties: 0, total_transactions: 0, reciprocal_ratio: 0, avg_tx_interval: 0, cluster_flag: false, last_analysis: 0, sybil_score: 0 }) + } + pub fn get_effective_score(env: Env, pioneer: Address) -> u32 { let p = Self::get_profile(env, pioneer.clone()); let sp = Self::get_sybil_profile(env, pioneer); - p.score - (p.score * sp.sybil_score / 20000) + p.score.saturating_sub(p.score * sp.sybil_score / 20000) } + pub fn verify_tier_claim(env: Env, pioneer: Address, claimed_tier: ReputationTier) -> bool { - let actual = Self::get_tier(env, pioneer); actual == claimed_tier + Self::get_tier(env, pioneer) == claimed_tier } } diff --git a/contracts/reputation/src/test.rs b/contracts/reputation/src/test.rs new file mode 100644 index 000000000..6c0c6b8b7 --- /dev/null +++ b/contracts/reputation/src/test.rs @@ -0,0 +1,70 @@ +#![no_std] +use soroban_sdk::{testutils::Address as _, Address, BytesN, Env}; +use crate::{ReputationContract, ReputationContractClient}; +use shared::{ReputationTier, SoulboundBadge, AttestationType}; + +fn setup_env() -> (Env, Address) { + let env = Env::default(); + env.mock_all_auths(); + let caller = Address::generate(&env); + (env, caller) +} + +#[test] +fn test_create_profile() { + let (env, _) = setup_env(); + let contract_id = env.register(ReputationContract, ()); + let client = ReputationContractClient::new(&env, &contract_id); + let pioneer = Address::generate(&env); + let profile = client.create_profile(&pioneer); + assert_eq!(profile.score, 200); + assert!(profile.tier == ReputationTier::Silver); +} + +#[test] +fn test_escrow_completion() { + let (env, caller) = setup_env(); + let contract_id = env.register(ReputationContract, ()); + let client = ReputationContractClient::new(&env, &contract_id); + let pioneer = Address::generate(&env); + client.create_profile(&pioneer); + let score = client.record_escrow_completion(&caller, &pioneer, &true); + assert_eq!(score, 205); +} + +#[test] +fn test_escrow_expiry() { + let (env, caller) = setup_env(); + let contract_id = env.register(ReputationContract, ()); + let client = ReputationContractClient::new(&env, &contract_id); + let seller = Address::generate(&env); + client.create_profile(&seller); + let score = client.record_escrow_expiry(&caller, &seller); + assert_eq!(score, 185); +} + +#[test] +fn test_award_badge() { + let (env, caller) = setup_env(); + let contract_id = env.register(ReputationContract, ()); + let client = ReputationContractClient::new(&env, &contract_id); + let pioneer = Address::generate(&env); + client.create_profile(&pioneer); + let reason = BytesN::from_array(&env, &[1u8; 32]); + client.award_badge(&caller, &pioneer, &SoulboundBadge::FirstTrade, &reason); + assert!(client.has_badge(&pioneer, &SoulboundBadge::FirstTrade)); + let profile = client.get_profile(&pioneer); + assert_eq!(profile.badge_count, 1); + assert_eq!(profile.score, 202); +} + +#[test] +fn test_verify_threshold() { + let (env, _) = setup_env(); + let contract_id = env.register(ReputationContract, ()); + let client = ReputationContractClient::new(&env, &contract_id); + let pioneer = Address::generate(&env); + client.create_profile(&pioneer); + assert!(client.verify_threshold(&pioneer, &200u32)); + assert!(!client.verify_threshold(&pioneer, &500u32)); +} From dc4727cf4cff57b985f8e338286188344e16b939 Mon Sep 17 00:00:00 2001 From: aybvip Date: Sun, 10 May 2026 18:30:30 +0100 Subject: [PATCH 3/3] fix: shared crate-type lib, add JurorVote/DisputeCase/GroupEscrow to shared, add .gitignore --- .gitignore | 20 ++++++++++++++++++++ contracts/shared/Cargo.toml | 2 +- contracts/shared/src/lib.rs | 12 ++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..b8a32fd97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Rust +target/ +**/*.rs.bk +*.swp +*.swo + +# Cargo +Cargo.lock + +# IDE +.vscode/ +.idea/ +*.iml + +# OS +.DS_Store +Thumbs.db + +# Soroban +.soroban/ diff --git a/contracts/shared/Cargo.toml b/contracts/shared/Cargo.toml index 5f09974fc..49e557cb7 100644 --- a/contracts/shared/Cargo.toml +++ b/contracts/shared/Cargo.toml @@ -4,7 +4,7 @@ version = "1.1.0" edition = "2021" [lib] -crate-type = ["cdylib"] +crate-type = ["lib"] [dependencies] soroban-sdk = { workspace = true } diff --git a/contracts/shared/src/lib.rs b/contracts/shared/src/lib.rs index 5933f7345..61c3410d5 100644 --- a/contracts/shared/src/lib.rs +++ b/contracts/shared/src/lib.rs @@ -95,6 +95,10 @@ pub struct Milestone { #[derive(Clone, Debug, Eq, PartialEq)] pub struct GroupParticipant { pub buyer: Address, pub amount: i128, pub funded: bool, pub funded_at: Option, pub refund_percentage: u32 } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GroupEscrow { pub escrow_id: u64, pub organizer: Address, pub seller: Address, pub token: Address, pub total_amount: i128, pub funded_amount: i128, pub state: GroupEscrowState, pub participants: Vec, pub created_at: u64, pub funding_deadline: u64, pub delivery_deadline: u64, pub auto_release_timeout: u64, pub order_metadata: BytesN<32> } + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct BadgeOwnership { pub pioneer: Address, pub badge: SoulboundBadge, pub awarded_at: u64, pub award_reason: BytesN<32>, pub revoked: bool } @@ -122,3 +126,11 @@ pub struct LoyaltyProfile { pub pioneer: Address, pub points: u32, pub tier: Loy #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct ModuleAddresses { pub escrow: Address, pub reputation: Address, pub dispute: Address, pub merchant_verification: Address, pub loyalty: Address } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct JurorVote { pub juror: Address, pub vote: DisputeRuling, pub confidence: u8, pub voted_at: u64, pub justification_hash: BytesN<32> } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DisputeCase { pub dispute_id: u64, pub escrow_id: u64, pub filer: Address, pub respondent: Address, pub category: DisputeCategory, pub phase: DisputePhase, pub jurors: Vec
, pub votes: Vec, pub filer_evidence: Vec>, pub respondent_evidence: Vec>, pub filed_at: u64, pub evidence_deadline: u64, pub voting_deadline: u64, pub ruling: Option, pub is_appealed: bool, pub appeal_fee: i128 }