Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions contracts/production_escrow/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,21 @@ pub fn emit_tranches_configured(env: &Env, campaign_id: u64, tranche_count: u32)
env.events().publish(topics, payload);
}

pub fn emit_harvest_reported(env: &Env, campaign_id: u64, farmer: Address) {
pub fn emit_harvest_reported(env: &Env, campaign_id: u64, farmer: Address, outcome: Symbol) {
let topics = (Symbol::new(env, "HarvestReported"), campaign_id);
let payload = (farmer, env.ledger().timestamp());
let payload = (farmer, outcome, env.ledger().timestamp());
env.events().publish(topics, payload);
}

pub fn emit_campaign_failed(env: &Env, campaign_id: u64, refundable: i128) {
let topics = (Symbol::new(env, "CampaignFailed"), campaign_id);
let payload = (env.ledger().timestamp(), refundable);
env.events().publish(topics, payload);
}

pub fn emit_return_claimed(env: &Env, campaign_id: u64, investor: Address, amount: i128) {
let topics = (Symbol::new(env, "ReturnClaimed"), campaign_id);
let payload = (investor, env.ledger().timestamp(), amount);
env.events().publish(topics, payload);
}

Expand Down Expand Up @@ -74,8 +86,14 @@ pub fn emit_refund_claimed(env: &Env, campaign_id: u64, investor: Address, amoun
env.events().publish(topics, payload);
}

pub fn emit_campaign_settled(env: &Env, campaign_id: u64, farmer: Address, final_amount: i128) {
pub fn emit_campaign_settled(
env: &Env,
campaign_id: u64,
farmer: Address,
farmer_payout: i128,
investor_returns: i128,
) {
let topics = (Symbol::new(env, "CampaignSettled"), campaign_id);
let payload = (farmer, env.ledger().timestamp(), final_amount);
let payload = (farmer, env.ledger().timestamp(), farmer_payout, investor_returns);
env.events().publish(topics, payload);
}
129 changes: 111 additions & 18 deletions contracts/production_escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ use soroban_sdk::{
#[contract]
pub struct ProductionEscrowContract;

/// Funds still held in escrow (not yet released or marked refundable).
/// Funds still held in escrow (not yet released, refundable, or returnable).
fn escrow_held(campaign: &Campaign) -> i128 {
campaign.total_funded - campaign.released - campaign.refundable
campaign.total_funded - campaign.released - campaign.refundable - campaign.returnable
}

fn require_admin(env: &Env) {
Expand All @@ -33,6 +33,7 @@ fn is_terminal(status: &CampaignStatus) -> bool {
CampaignStatus::Disputed
| CampaignStatus::Resolved
| CampaignStatus::Settled
| CampaignStatus::Failed
)
}

Expand Down Expand Up @@ -74,6 +75,7 @@ impl ProductionEscrowContract {
total_funded: 0,
released: 0,
refundable: 0,
returnable: 0,
status: CampaignStatus::Active,
};
storage::set_campaign(&env, campaign_id, &campaign);
Expand Down Expand Up @@ -244,14 +246,34 @@ impl ProductionEscrowContract {
emit_tranche_released(&env, campaign_id, recipient, amount);
}

pub fn report_harvest(env: Env, campaign_id: u64, farmer: Address) {
let campaign = storage::get_campaign(&env, campaign_id);
/// Farmer reports the harvest outcome, moving the campaign to Harvested.
/// Only the campaign farmer or admin may call this.
pub fn report_harvest(env: Env, campaign_id: u64, farmer: Address, outcome: Symbol) {
let mut campaign = storage::get_campaign(&env, campaign_id);

let is_admin = storage::has_admin(&env) && storage::get_admin(&env) == farmer;
if campaign.farmer != farmer && !is_admin {
panic!("not authorized to report harvest");
}
farmer.require_auth();

if campaign.status != CampaignStatus::Funded {
panic!("campaign not funded");
}

let record = HarvestRecord {
farmer: farmer.clone(),
outcome: outcome.clone(),
timestamp: env.ledger().timestamp(),
ledger_sequence: env.ledger().sequence(),
};
storage::set_harvest_record(&env, campaign_id, &record);

campaign.status = CampaignStatus::Harvested;
storage::set_campaign(&env, campaign_id, &campaign);
storage::extend_instance_ttl(&env);

emit_harvest_reported(&env, campaign_id, farmer);
emit_harvest_reported(&env, campaign_id, farmer, outcome);
}

pub fn open_dispute(env: Env, campaign_id: u64, opener: Address, reason: Symbol) {
Expand Down Expand Up @@ -357,14 +379,16 @@ impl ProductionEscrowContract {
);
}

/// Investor claims their pro-rata refund from a Resolved or Failed campaign.
pub fn claim_refund(env: Env, campaign_id: u64, investor: Address) {
let campaign = storage::get_campaign(&env, campaign_id);
if campaign.status != CampaignStatus::Resolved {
if campaign.status != CampaignStatus::Resolved
&& campaign.status != CampaignStatus::Failed
{
panic!("no refund available");
}

let contributed =
storage::get_contribution(&env, campaign_id, &investor);
let contributed = storage::get_contribution(&env, campaign_id, &investor);
if contributed <= 0 {
panic!("nothing to refund");
}
Expand All @@ -376,33 +400,98 @@ impl ProductionEscrowContract {
investor.require_auth();

storage::set_contribution(&env, campaign_id, &investor, 0);
storage::extend_instance_ttl(&env);

let token = TokenClient::new(&env, &campaign.token_address);
token.transfer(&env.current_contract_address(), &investor, &share);

storage::extend_instance_ttl(&env);
emit_refund_claimed(&env, campaign_id, investor, share);
}

pub fn settle_campaign(env: Env, campaign_id: u64, farmer: Address, final_amount: i128) {
if final_amount <= 0 {
panic!("amount must be positive");
/// Admin settles a harvested campaign: specifies how much goes to the farmer;
/// the remainder becomes proportionally returnable to investors.
pub fn settle_campaign(env: Env, campaign_id: u64, farmer: Address, farmer_payout: i128) {
if farmer_payout < 0 {
panic!("payout cannot be negative");
}

require_admin(&env);

let mut campaign = storage::get_campaign(&env, campaign_id);
if campaign.status == CampaignStatus::Disputed {
panic!("campaign is disputed");
}
if campaign.status != CampaignStatus::Funded {
panic!("campaign not funded");
if campaign.status != CampaignStatus::Harvested {
panic!("campaign not harvested");
}
if final_amount > escrow_held(&campaign) {
panic!("amount exceeds escrow balance");

let held = escrow_held(&campaign);
if farmer_payout > held {
panic!("payout exceeds escrow balance");
}

let investor_returns = held - farmer_payout;

if farmer_payout > 0 {
let token = TokenClient::new(&env, &campaign.token_address);
token.transfer(&env.current_contract_address(), &farmer, &farmer_payout);
}

campaign.released += final_amount;
campaign.released += farmer_payout;
campaign.returnable += investor_returns;
campaign.status = CampaignStatus::Settled;
storage::set_campaign(&env, campaign_id, &campaign);
storage::extend_instance_ttl(&env);

emit_campaign_settled(&env, campaign_id, farmer, final_amount);
emit_campaign_settled(&env, campaign_id, farmer, farmer_payout, investor_returns);
}

/// Admin marks a campaign as failed, making escrowed funds refundable to investors.
pub fn mark_failed(env: Env, campaign_id: u64) {
require_admin(&env);

let mut campaign = storage::get_campaign(&env, campaign_id);
if campaign.status != CampaignStatus::Active
&& campaign.status != CampaignStatus::Funding
&& campaign.status != CampaignStatus::Funded
{
panic!("campaign cannot be marked failed in its current state");
}

let held = escrow_held(&campaign);
campaign.refundable += held;
campaign.status = CampaignStatus::Failed;
storage::set_campaign(&env, campaign_id, &campaign);
storage::extend_instance_ttl(&env);

emit_campaign_failed(&env, campaign_id, campaign.refundable);
}

/// Investor claims their pro-rata share of investor returns from a Settled campaign.
pub fn claim_return(env: Env, campaign_id: u64, investor: Address) {
let campaign = storage::get_campaign(&env, campaign_id);
if campaign.status != CampaignStatus::Settled {
panic!("campaign not settled");
}

let contributed = storage::get_contribution(&env, campaign_id, &investor);
if contributed <= 0 {
panic!("nothing to return");
}

let share = contributed * campaign.returnable / campaign.total_funded;
if share <= 0 {
panic!("nothing to return");
}
investor.require_auth();

storage::set_contribution(&env, campaign_id, &investor, 0);

let token = TokenClient::new(&env, &campaign.token_address);
token.transfer(&env.current_contract_address(), &investor, &share);

storage::extend_instance_ttl(&env);
emit_return_claimed(&env, campaign_id, investor, share);
}
pub fn get_campaign(env: Env, campaign_id: u64) -> Campaign {
storage::get_campaign(&env, campaign_id)
Expand All @@ -419,6 +508,10 @@ impl ProductionEscrowContract {
pub fn get_tranches(env: Env, campaign_id: u64) -> Vec<Tranche> {
storage::get_tranches(&env, campaign_id)
}

pub fn get_harvest_record(env: Env, campaign_id: u64) -> HarvestRecord {
storage::get_harvest_record(&env, campaign_id)
}
}

#[cfg(test)]
Expand Down
15 changes: 14 additions & 1 deletion contracts/production_escrow/src/storage.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::types::{Campaign, DataKey, Dispute, Tranche, TrancheList};
use crate::types::{Campaign, DataKey, Dispute, HarvestRecord, TrancheList};
use soroban_sdk::{Address, Env, Vec};

const DAY_IN_LEDGERS: u32 = 17280;
Expand Down Expand Up @@ -91,3 +91,16 @@ pub fn set_tranches(env: &Env, campaign_id: u64, tranches: &TrancheList) {
env.storage().persistent().set(&key, tranches);
extend_persistent_ttl(env, &key);
}

pub fn get_harvest_record(env: &Env, campaign_id: u64) -> HarvestRecord {
let key = DataKey::HarvestRecord(campaign_id);
let record = env.storage().persistent().get(&key).unwrap();
extend_persistent_ttl(env, &key);
record
}

pub fn set_harvest_record(env: &Env, campaign_id: u64, record: &HarvestRecord) {
let key = DataKey::HarvestRecord(campaign_id);
env.storage().persistent().set(&key, record);
extend_persistent_ttl(env, &key);
}
Loading