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
213 changes: 213 additions & 0 deletions smart-contracts/contracts/agent_bidding/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand Down
16 changes: 16 additions & 0 deletions smart-contracts/contracts/agent_bidding/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
}
Loading