diff --git a/smart-contracts/contracts/agent_bidding/src/lib.rs b/smart-contracts/contracts/agent_bidding/src/lib.rs index 65294f2..c92c8dd 100644 --- a/smart-contracts/contracts/agent_bidding/src/lib.rs +++ b/smart-contracts/contracts/agent_bidding/src/lib.rs @@ -937,6 +937,72 @@ impl AgentBiddingContract { Ok(()) } + // ── Claim Refund ──────────────────────────────────────────────────────── + + /// Let a bidder reclaim their own bond directly, without depending on the + /// creator ever calling `reveal_bids`/`award_contract`. + /// + /// `award_contract` already refunds every bidder's bond automatically + /// once the auction resolves normally — this function exists for the + /// case it never does (the creator goes silent, nobody reveals, the + /// auction stalls in `Bidding`/`Reveal` forever). Without it, a bidder + /// whose bond is stuck in an abandoned auction has no way to get it back. + /// + /// Idempotency and "proof of loss" both fall out of the same check: a + /// bond that's already `refunded` — whether from a prior claim or from + /// `award_contract` (which includes the winner) — cannot be claimed + /// again, so there's no need to separately look up who the winner was. + /// + /// Only callable once the bidding period has definitively closed + /// (`now >= auction.deadline`) and before the claim window's resolution + /// deadline (`auction.deadline + CLAIM_WINDOW_SECS`) elapses. Emits + /// `(bidding, refnd_clm)`. + pub fn claim_refund(env: Env, task_id: Symbol, bidder: Address) -> Result<(), Error> { + bidder.require_auth(); + + let auct_key = DataKey::Auction(task_id.clone()); + let auction: Auction = env + .storage() + .persistent() + .get(&auct_key) + .ok_or(Error::NotFound)?; + + let now = env.ledger().timestamp(); + if now < auction.deadline { + return Err(Error::BiddingPeriodActive); + } + if now >= auction.deadline.saturating_add(CLAIM_WINDOW_SECS) { + return Err(Error::ClaimWindowExpired); + } + + let bid_key = DataKey::Bid(task_id.clone(), bidder.clone()); + let mut bid: SealedBid = env + .storage() + .persistent() + .get(&bid_key) + .ok_or(Error::NotFound)?; + + if bid.refunded { + return Err(Error::AlreadyRefunded); + } + + bid.refunded = true; + let bond = bid.bond; + env.storage().persistent().set(&bid_key, &bid); + extend_ttl_for_key(&env, &bid_key); + + env.events().publish( + (symbol_short!("bidding"), symbol_short!("refnd_clm")), + RefundClaimedEvent { + task_id, + bidder, + bond, + }, + ); + + Ok(()) + } + // ── View Functions ───────────────────────────────────────────────────── /// Compute the commitment an off-chain bidder must submit for this task. @@ -2407,6 +2473,153 @@ mod test { assert!(client.get_bid(&task_id, &bidder).unwrap().revealed); } + // ── claim_refund ───────────────────────────────────────────────────────── + + #[test] + fn claim_refund_before_deadline_fails() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let task_id = Symbol::new(&env, "claim_early"); + create_test_auction(&env, &client, &creator, &task_id, 3600); + + let bidder = Address::generate(&env); + let salt = BytesN::<32>::from_array(&env, &[21u8; 32]); + let comm = test_commitment(&env, &bidder, 2_000_000, &String::from_str(&env, "x"), &salt); + client.submit_bid(&task_id, &bidder, &comm, &500_000, &50); + + // Bidding period is still open. + let err = client.try_claim_refund(&task_id, &bidder); + assert_eq!(err.err(), Some(Ok(Error::BiddingPeriodActive))); + } + + #[test] + fn claim_refund_with_no_bid_fails() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let task_id = Symbol::new(&env, "claim_no_bid"); + create_test_auction(&env, &client, &creator, &task_id, 3600); + + env.ledger().set_timestamp(env.ledger().timestamp() + 3601); + + let stranger = Address::generate(&env); + let err = client.try_claim_refund(&task_id, &stranger); + assert_eq!(err.err(), Some(Ok(Error::NotFound))); + } + + #[test] + fn claim_refund_recovers_bond_from_a_stalled_auction() { + // Creator never calls reveal_bids/award_contract — bidding closes and + // the auction just sits there. Without claim_refund the bidder's + // bond would be stuck forever. + let (env, client) = setup(); + let creator = Address::generate(&env); + let task_id = Symbol::new(&env, "claim_stalled"); + create_test_auction(&env, &client, &creator, &task_id, 3600); + + let bidder = Address::generate(&env); + let salt = BytesN::<32>::from_array(&env, &[22u8; 32]); + let comm = test_commitment(&env, &bidder, 2_000_000, &String::from_str(&env, "x"), &salt); + client.submit_bid(&task_id, &bidder, &comm, &500_000, &50); + + env.ledger().set_timestamp(env.ledger().timestamp() + 3601); + + assert!(!client.get_bid(&task_id, &bidder).unwrap().refunded); + + client.claim_refund(&task_id, &bidder); + + assert!(client.get_bid(&task_id, &bidder).unwrap().refunded); + } + + #[test] + fn claim_refund_twice_fails_idempotency() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let task_id = Symbol::new(&env, "claim_twice"); + create_test_auction(&env, &client, &creator, &task_id, 3600); + + let bidder = Address::generate(&env); + let salt = BytesN::<32>::from_array(&env, &[23u8; 32]); + let comm = test_commitment(&env, &bidder, 2_000_000, &String::from_str(&env, "x"), &salt); + client.submit_bid(&task_id, &bidder, &comm, &500_000, &50); + + env.ledger().set_timestamp(env.ledger().timestamp() + 3601); + + client.claim_refund(&task_id, &bidder); + + let err = client.try_claim_refund(&task_id, &bidder); + assert_eq!(err.err(), Some(Ok(Error::AlreadyRefunded))); + } + + #[test] + fn claim_refund_after_window_expires_fails() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let task_id = Symbol::new(&env, "claim_expired"); + create_test_auction(&env, &client, &creator, &task_id, 3600); + + let bidder = Address::generate(&env); + let salt = BytesN::<32>::from_array(&env, &[24u8; 32]); + let comm = test_commitment(&env, &bidder, 2_000_000, &String::from_str(&env, "x"), &salt); + client.submit_bid(&task_id, &bidder, &comm, &500_000, &50); + + // Past deadline + the full claim window. + env.ledger() + .set_timestamp(env.ledger().timestamp() + 3601 + CLAIM_WINDOW_SECS); + + let err = client.try_claim_refund(&task_id, &bidder); + assert_eq!(err.err(), Some(Ok(Error::ClaimWindowExpired))); + } + + #[test] + fn claim_refund_after_normal_award_is_a_noop_error_not_a_double_payout() { + // award_contract already refunded this bidder automatically — + // claim_refund must recognise that via the idempotency check rather + // than re-refunding (there's nothing to "re-refund" on-chain, but the + // point is it must not treat this as a fresh, valid claim). + let (env, client) = setup(); + let creator = Address::generate(&env); + let task_id = Symbol::new(&env, "claim_after_award"); + create_test_auction(&env, &client, &creator, &task_id, 3600); + + let bidder = Address::generate(&env); + let salt = BytesN::<32>::from_array(&env, &[25u8; 32]); + let price = 2_000_000i128; + let terms = String::from_str(&env, "Solo"); + let comm = test_commitment(&env, &bidder, price, &terms, &salt); + client.submit_bid(&task_id, &bidder, &comm, &500_000, &50); + + env.ledger().set_timestamp(env.ledger().timestamp() + 3601); + client.reveal_bid(&task_id, &bidder, &price, &terms, &salt); + client.reveal_bids(&task_id); + client.award_contract(&task_id); + + assert!(client.get_bid(&task_id, &bidder).unwrap().refunded); + + let err = client.try_claim_refund(&task_id, &bidder); + assert_eq!(err.err(), Some(Ok(Error::AlreadyRefunded))); + } + + #[test] + fn claim_refund_emits_exactly_one_event() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let task_id = Symbol::new(&env, "claim_event"); + create_test_auction(&env, &client, &creator, &task_id, 3600); + + let bidder = Address::generate(&env); + let salt = BytesN::<32>::from_array(&env, &[26u8; 32]); + let comm = test_commitment(&env, &bidder, 2_000_000, &String::from_str(&env, "x"), &salt); + client.submit_bid(&task_id, &bidder, &comm, &500_000, &50); + + env.ledger().set_timestamp(env.ledger().timestamp() + 3601); + let _ = env.events().all(); // drain + + client.claim_refund(&task_id, &bidder); + + let events = env.events().all(); + assert_eq!(events.len(), 1, "expected exactly one RefundClaimed event"); + } + // ── Full end-to-end flow ───────────────────────────────────────────────── #[test] diff --git a/smart-contracts/contracts/agent_bidding/src/types.rs b/smart-contracts/contracts/agent_bidding/src/types.rs index 9ac583b..b747168 100644 --- a/smart-contracts/contracts/agent_bidding/src/types.rs +++ b/smart-contracts/contracts/agent_bidding/src/types.rs @@ -88,6 +88,13 @@ pub const MAX_TERMS_LEN: u32 = 512; /// Each sub-score (price, reputation) is normalised to `[0, SCORE_SCALE]`. pub const SCORE_SCALE: i128 = 1_000; +/// How long after the bidding deadline a bidder may call `claim_refund` for +/// an auction that never reached `Awarded` (creator went silent, nobody +/// revealed, etc). Opens at `deadline` (bidding has definitively closed) and +/// closes at `deadline + CLAIM_WINDOW_SECS` — the "resolution deadline" the +/// acceptance criteria refers to. Default: 7 days. +pub const CLAIM_WINDOW_SECS: u64 = 604_800; + /// Weightings for the composite score (must sum to 100). pub const PRICE_WEIGHT: i128 = 60; pub const REPUTATION_WEIGHT: i128 = 40; @@ -330,3 +337,12 @@ pub struct AuctionAbortedEvent { /// Number of bidders whose bond was released by the abort. pub refunded_bidders: u32, } + +/// Emitted when `claim_refund` successfully refunds a bidder's bond. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RefundClaimedEvent { + pub task_id: Symbol, + pub bidder: Address, + pub bond: i128, +}