diff --git a/dao/src/bid_escrow/bid.rs b/dao/src/bid_escrow/bid.rs index 7d2f3aa5..fc7a42ee 100644 --- a/dao/src/bid_escrow/bid.rs +++ b/dao/src/bid_escrow/bid.rs @@ -13,7 +13,7 @@ use odra::types::{Address, Balance, BlockTime}; use odra::OdraType; /// Bid status representation -#[derive(OdraType, PartialEq)] +#[derive(OdraType, PartialEq, Debug)] pub enum BidStatus { /// Placed, awaiting to be picked. Created, diff --git a/dao/src/bid_escrow/bid_engine.rs b/dao/src/bid_escrow/bid_engine.rs index 1119bf67..9f70279c 100644 --- a/dao/src/bid_escrow/bid_engine.rs +++ b/dao/src/bid_escrow/bid_engine.rs @@ -18,6 +18,7 @@ use crate::configuration::{Configuration, ConfigurationBuilder}; use crate::modules::refs::ContractRefs; use crate::utils::withdraw; use alloc::rc::Rc; +use odra::contract_env; use odra::contract_env::{caller, get_block_time}; use odra::prelude::{vec, vec::Vec}; use odra::types::{event::OdraEvent, Address, Balance, BlockTime}; @@ -231,7 +232,8 @@ impl BidEngine { block_time: get_block_time(), timeframe: bid.proposed_timeframe, payment: bid.proposed_payment, - transferred_cspr: cspr_amount, + transferred_cspr: contract_env::attached_value(), + cspr_amount, stake: bid.reputation_stake, external_worker_cspr_stake: bid.cspr_stake.unwrap_or_default(), }; diff --git a/dao/src/bid_escrow/job.rs b/dao/src/bid_escrow/job.rs index 8f293a98..e069649f 100644 --- a/dao/src/bid_escrow/job.rs +++ b/dao/src/bid_escrow/job.rs @@ -48,6 +48,8 @@ pub struct PickBidRequest { pub payment: Balance, /// The amount transferred by `Job Poster`. pub transferred_cspr: Balance, + /// The amount declared to be transferred by `Job Poster`. + pub cspr_amount: Balance, /// Bid reputation stake. pub stake: Balance, /// Bid CSPR stake - for an [External Worker](crate::bid_escrow#definitions). @@ -114,6 +116,7 @@ impl Job { .add_validation(DoesProposedPaymentMatchTransferred::create( request.payment, request.transferred_cspr, + request.cspr_amount, )) .build() .validate_generic_validations(); diff --git a/dao/src/rules/validation/bid_escrow/does_proposed_payment_match_transferred.rs b/dao/src/rules/validation/bid_escrow/does_proposed_payment_match_transferred.rs index 8ee07824..780f1b60 100644 --- a/dao/src/rules/validation/bid_escrow/does_proposed_payment_match_transferred.rs +++ b/dao/src/rules/validation/bid_escrow/does_proposed_payment_match_transferred.rs @@ -8,11 +8,15 @@ use odra::types::Balance; pub struct DoesProposedPaymentMatchTransferred { proposed_payment: Balance, transferred: Balance, + declared: Balance, } impl Validation for DoesProposedPaymentMatchTransferred { fn validate(&self) -> Result<(), Error> { - if self.proposed_payment != self.transferred { + if (self.proposed_payment != self.transferred) + || (self.proposed_payment != self.declared) + || self.transferred != self.declared + { return Err(Error::PurseBalanceMismatch); } diff --git a/dao/tests/common/contracts/bid_escrow.rs b/dao/tests/common/contracts/bid_escrow.rs index 973351d8..def04e2e 100644 --- a/dao/tests/common/contracts/bid_escrow.rs +++ b/dao/tests/common/contracts/bid_escrow.rs @@ -7,13 +7,6 @@ use odra::test_env; use odra::types::{Balance, BlockTime}; impl DaoWorld { - pub fn get_bid(&self, offer_id: JobOfferId, poster: Account) -> Option { - let poster = self.get_address(&poster); - let bid_id = self.bids.get(&(offer_id, poster))?; - - self.bid_escrow.get_bid(*bid_id) - } - pub fn get_job_offer_id(&self, job_poster: &Account) -> Option<&JobOfferId> { let job_poster = self.get_address(job_poster); self.offers.get(&job_poster) @@ -50,6 +43,13 @@ impl DaoWorld { self.bids.insert((offer_id, bidder), bid_id); } + pub fn get_bid(&self, offer_id: JobOfferId, poster: Account) -> Option { + let poster = self.get_address(&poster); + let bid_id = self.bids.get(&(offer_id, poster))?; + + self.bid_escrow.get_bid(*bid_id) + } + pub fn cancel_bid(&mut self, worker: Account, job_offer_id: JobOfferId, bid_id: BidId) { let worker = self.get_address(&worker); test_env::set_caller(worker); @@ -97,6 +97,26 @@ impl DaoWorld { ); } + pub fn pick_bid_without_enough_payment(&mut self, job_poster: Account, worker: Account) { + let job_poster = self.get_address(&job_poster); + let worker = self.get_address(&worker); + let job_offer_id = self.offers.get(&job_poster).expect("Job Offer not found."); + let bid_id = self + .bids + .get(&(*job_offer_id, worker)) + .expect("Bid id not found."); + let bid = self.bid_escrow.get_bid(*bid_id).expect("Bid not found."); + let payment = bid.proposed_payment - Balance::one(); + test_env::assert_exception(Error::PurseBalanceMismatch, || { + test_env::set_caller(job_poster); + self.bid_escrow.with_tokens(payment).pick_bid( + *job_offer_id, + *bid_id, + bid.proposed_payment, + ); + }); + } + // pub fn slash_all_active_job_offers(&mut self, bidder: Account) { // let bidder = self.get_address(&bidder); // self.bid_escrow.slash_all_active_job_offers(bidder); diff --git a/dao/tests/features/bid_escrow/picking_a_bid_without_paying.feature b/dao/tests/features/bid_escrow/picking_a_bid_without_paying.feature new file mode 100644 index 00000000..5574ec8d --- /dev/null +++ b/dao/tests/features/bid_escrow/picking_a_bid_without_paying.feature @@ -0,0 +1,41 @@ +Feature: Picking a bid without paying + JobPoster posts a job, internal worker is bidding. + Job Poster picks a bid of an Internal Worker, without sending exact amount of CSPR. + Picking a bid is rejected. + This is a presentation of HAL-01 issue fix. + Background: + Given following balances + | account | CSPR balance | REP balance | REP stake | is_kyced | is_va | + | BidEscrow | 0 | 0 | 0 | false | false | + | MultisigWallet | 0 | 0 | 0 | false | false | + | JobPoster | 1000 | 0 | 0 | true | false | + | InternalWorker | 0 | 1000 | 0 | true | true | + | VA1 | 0 | 1000 | 0 | true | true | + | VA2 | 0 | 1000 | 0 | true | true | + And following configuration + | key | value | + | TimeBetweenInformalAndFormalVoting | 0 | + | VotingStartAfterJobSubmission | 0 | + When JobPoster posted a JobOffer with expected timeframe of 14 days, maximum budget of 1000 CSPR and 400 CSPR DOS Fee + And InternalWorker posted the Bid for JobOffer 0 with proposed timeframe of 7 days and 500 CSPR price and 100 REP stake + And 8 days passed + Then balances are + | account | CSPR balance | REP balance | REP stake | + # Initial 1000 + 400 dos fee. Notice lack of 500 CSPR payment from the bid. + | BidEscrow | 400 | 0 | 0 | + | JobPoster | 600 | 0 | 0 | + | InternalWorker | 0 | 1000 | 100 | + | VA1 | 0 | 1000 | 0 | + | VA2 | 0 | 1000 | 0 | + Scenario: JobPoster picked the Bid of InternalWorker without sending exact amount of CSPR + # Following step will fail, before fix it would pass + When JobPoster picked the Bid without paying for InternalWorker + Then balances are + | account | CSPR balance | REP balance | REP stake | + # Initial 1000 + 400 dos fee. Notice lack of 500 CSPR payment from the bid. + | BidEscrow | 400 | 0 | 0 | + | JobPoster | 600 | 0 | 0 | + | InternalWorker | 0 | 1000 | 100 | + | VA1 | 0 | 1000 | 0 | + | VA2 | 0 | 1000 | 0 | + And the Bid of InternalWorker is in state Created \ No newline at end of file diff --git a/dao/tests/steps/bid_escrow.rs b/dao/tests/steps/bid_escrow.rs index 1abb72e7..2bc81965 100644 --- a/dao/tests/steps/bid_escrow.rs +++ b/dao/tests/steps/bid_escrow.rs @@ -1,4 +1,5 @@ use cucumber::{then, when}; +use dao::bid_escrow::bid::BidStatus; use dao::bid_escrow::contract::BidEscrowContractRef; use dao::bid_escrow::job::JobStatus; use dao::bid_escrow::job_offer::JobOfferStatus; @@ -128,6 +129,11 @@ fn bid_picked(w: &mut DaoWorld, job_poster: Account, worker: Account) { w.pick_bid(job_poster, worker); } +#[when(expr = "{account} picked the Bid without paying for {account}")] +fn bid_picked_without_paying(w: &mut DaoWorld, job_poster: Account, worker: Account) { + w.pick_bid_without_enough_payment(job_poster, worker); +} + #[when(expr = "{account} submits the JobProof of Job {int}")] fn submit_job_proof(w: &mut DaoWorld, worker: Account, job_id: JobId) { let worker = w.get_address(&worker); @@ -226,6 +232,13 @@ fn assert_job_offer_status(world: &mut DaoWorld, job_poster: Account, job_offer_ }; } +#[then(expr = "the Bid of InternalWorker is in state Created")] +fn assert_bid_status(world: &mut DaoWorld) { + let job_poster = Account::InternalWorker; + let bid = world.get_bid(0, job_poster).unwrap(); + assert_eq!(bid.status, BidStatus::Created); +} + #[then(expr = "{account} cannot submit the JobProof of Job {int}")] fn cannot_submit_job_proof(w: &mut DaoWorld, worker: Account, job_id: JobId) { let worker = w.get_address(&worker);