diff --git a/gateway-contract/contracts/auction_contract/src/lib.rs b/gateway-contract/contracts/auction_contract/src/lib.rs index dc0e0a3..f604bd9 100644 --- a/gateway-contract/contracts/auction_contract/src/lib.rs +++ b/gateway-contract/contracts/auction_contract/src/lib.rs @@ -129,25 +129,71 @@ impl AuctionContract { pub fn place_bid(env: Env, id: u32, bidder: Address, amount: i128) { bidder.require_auth(); + let status = storage::auction_get_status(&env, id); + if status != types::AuctionStatus::Open { + soroban_sdk::panic_with_error!(&env, errors::AuctionError::AuctionNotOpen); + } + let end_time = storage::auction_get_end_time(&env, id); if env.ledger().timestamp() >= end_time { soroban_sdk::panic_with_error!(&env, errors::AuctionError::AuctionNotOpen); } + let min_bid = storage::auction_get_min_bid(&env, id); let highest_bid = storage::auction_get_highest_bid(&env, id); if amount < min_bid || amount <= highest_bid { soroban_sdk::panic_with_error!(&env, errors::AuctionError::BidTooLow); } + let asset = storage::auction_get_asset(&env, id); let token = soroban_sdk::token::Client::new(&env, &asset); + + // Accept bid funds into contract. token.transfer(&bidder, env.current_contract_address(), &amount); + if let Some(prev_bidder) = storage::auction_get_highest_bidder(&env, id) { - token.transfer(&env.current_contract_address(), &prev_bidder, &highest_bid); + // Record outbid amount for later refund by the bidder. + let prev_amount = highest_bid; + let existing_outbid = storage::auction_get_outbid_amount(&env, id, &prev_bidder); + storage::auction_set_outbid_amount(&env, id, &prev_bidder, existing_outbid + prev_amount); } + storage::auction_set_highest_bidder(&env, id, &bidder); storage::auction_set_highest_bid(&env, id, amount); } + pub fn refund_bid(env: Env, id: u32, bidder: Address) { + bidder.require_auth(); + + let status = storage::auction_get_status(&env, id); + if status != types::AuctionStatus::Closed { + soroban_sdk::panic_with_error!(&env, errors::AuctionError::NotClosed); + } + + let highest_bidder = storage::auction_get_highest_bidder(&env, id); + if highest_bidder.as_ref().map(|h| h == &bidder).unwrap_or(false) { + soroban_sdk::panic_with_error!(&env, errors::AuctionError::NotWinner); + } + + if storage::auction_is_bid_refunded(&env, id, &bidder) { + soroban_sdk::panic_with_error!(&env, errors::AuctionError::AlreadyClaimed); + } + + let refund_amount = storage::auction_get_outbid_amount(&env, id, &bidder); + if refund_amount <= 0 { + soroban_sdk::panic_with_error!(&env, errors::AuctionError::InvalidState); + } + + let asset = storage::auction_get_asset(&env, id); + let token = soroban_sdk::token::Client::new(&env, &asset); + + storage::auction_set_bid_refunded(&env, id, &bidder); + storage::auction_set_outbid_amount(&env, id, &bidder, 0); + + token.transfer(&env.current_contract_address(), &bidder, &refund_amount); + events::emit_bid_refunded(&env, &BytesN::from_array(&env, &[0u8; 32]), &bidder, refund_amount); + } + pub fn close_auction_by_id(env: Env, id: u32) { let end_time = storage::auction_get_end_time(&env, id); if env.ledger().timestamp() < end_time { diff --git a/gateway-contract/contracts/auction_contract/src/storage.rs b/gateway-contract/contracts/auction_contract/src/storage.rs index 1c78a10..d92a831 100644 --- a/gateway-contract/contracts/auction_contract/src/storage.rs +++ b/gateway-contract/contracts/auction_contract/src/storage.rs @@ -198,3 +198,38 @@ pub fn auction_set_claimed(env: &Env, id: u32) { PERSISTENT_BUMP_AMOUNT, ); } + +pub fn auction_get_outbid_amount(env: &Env, id: u32, bidder: &Address) -> i128 { + env.storage() + .persistent() + .get(&AuctionKey::OutbidAmount(id, bidder.clone())) + .unwrap_or(0) +} + +pub fn auction_set_outbid_amount(env: &Env, id: u32, bidder: &Address, amount: i128) { + let key = AuctionKey::OutbidAmount(id, bidder.clone()); + env.storage().persistent().set(&key, &amount); + env.storage().persistent().extend_ttl( + &key, + PERSISTENT_LIFETIME_THRESHOLD, + PERSISTENT_BUMP_AMOUNT, + ); +} + +pub fn auction_is_bid_refunded(env: &Env, id: u32, bidder: &Address) -> bool { + env.storage() + .persistent() + .get(&AuctionKey::BidRefunded(id, bidder.clone())) + .unwrap_or(false) +} + +pub fn auction_set_bid_refunded(env: &Env, id: u32, bidder: &Address) { + let key = AuctionKey::BidRefunded(id, bidder.clone()); + env.storage().persistent().set(&key, &true); + env.storage().persistent().extend_ttl( + &key, + PERSISTENT_LIFETIME_THRESHOLD, + PERSISTENT_BUMP_AMOUNT, + ); +} + diff --git a/gateway-contract/contracts/auction_contract/src/test.rs b/gateway-contract/contracts/auction_contract/src/test.rs index e6011ae..e2bb47d 100644 --- a/gateway-contract/contracts/auction_contract/src/test.rs +++ b/gateway-contract/contracts/auction_contract/src/test.rs @@ -234,17 +234,93 @@ fn test_auction_full_lifecycle() { client.place_bid(&1, &bidder1, &150); client.place_bid(&1, &bidder2, &200); - // bidder1 refunded, bidder2 holds 800 - assert_eq!(token.balance(&bidder1), 1000); + // bidder1 is outbid and funds are held for refund; bidder2 is highest bidder. + assert_eq!(token.balance(&bidder1), 850); assert_eq!(token.balance(&bidder2), 800); env.ledger().set_timestamp(1001); client.close_auction_by_id(&1); - client.claim(&1, &bidder2); + client.refund_bid(&1, &bidder1); + assert_eq!(token.balance(&bidder1), 1000); + + client.claim(&1, &bidder2); assert_eq!(token.balance(&seller), 200); } +#[test] +fn test_refund_bid_success() { + let env = Env::default(); + env.mock_all_auths(); + let (client, seller, asset) = setup(&env); + let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &asset); + let token = soroban_sdk::token::Client::new(&env, &asset); + let bidder1 = Address::generate(&env); + let bidder2 = Address::generate(&env); + + token_admin.mint(&bidder1, &1000); + token_admin.mint(&bidder2, &1000); + + client.create_auction(&1, &seller, &asset, &100, &1000u64); + client.place_bid(&1, &bidder1, &150); + client.place_bid(&1, &bidder2, &200); + + env.ledger().set_timestamp(1001); + client.close_auction_by_id(&1); + + client.refund_bid(&1, &bidder1); + + assert_eq!(token.balance(&bidder1), 1000); + assert_eq!(token.balance(&bidder2), 800); +} + +#[test] +#[should_panic(expected = "Error(Contract, #1)")] +fn test_refund_bid_winner_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, seller, asset) = setup(&env); + let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &asset); + let bidder1 = Address::generate(&env); + let bidder2 = Address::generate(&env); + + token_admin.mint(&bidder1, &1000); + token_admin.mint(&bidder2, &1000); + + client.create_auction(&1, &seller, &asset, &100, &1000u64); + client.place_bid(&1, &bidder1, &150); + client.place_bid(&1, &bidder2, &200); + + env.ledger().set_timestamp(1001); + client.close_auction_by_id(&1); + + client.refund_bid(&1, &bidder2); +} + +#[test] +#[should_panic(expected = "Error(Contract, #2)")] +fn test_refund_bid_double_refund_panics() { + let env = Env::default(); + env.mock_all_auths(); + let (client, seller, asset) = setup(&env); + let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &asset); + let bidder1 = Address::generate(&env); + let bidder2 = Address::generate(&env); + + token_admin.mint(&bidder1, &1000); + token_admin.mint(&bidder2, &1000); + + client.create_auction(&1, &seller, &asset, &100, &1000u64); + client.place_bid(&1, &bidder1, &150); + client.place_bid(&1, &bidder2, &200); + + env.ledger().set_timestamp(1001); + client.close_auction_by_id(&1); + + client.refund_bid(&1, &bidder1); + client.refund_bid(&1, &bidder1); +} + #[test] fn test_auction_no_bids_close() { let env = Env::default(); diff --git a/gateway-contract/contracts/core_contract/src/test.rs b/gateway-contract/contracts/core_contract/src/test.rs index 2252621..c59622c 100644 --- a/gateway-contract/contracts/core_contract/src/test.rs +++ b/gateway-contract/contracts/core_contract/src/test.rs @@ -373,6 +373,70 @@ fn test_resolve_stellar_linked_address_differs_from_owner() { assert_eq!(resolved, payment_address); } +#[test] +fn test_resolve_stellar_owner_is_linked_address() { + let env = Env::default(); + env.mock_all_auths(); + let (_, client) = setup(&env); + + let owner = Address::generate(&env); + let hash = commitment(&env, 41); + + client.register(&owner, &hash); + client.add_stellar_address(&owner, &hash, &owner); + + let resolved = client.resolve_stellar(&hash); + assert_eq!(resolved, owner); +} + +#[test] +fn test_resolve_stellar_after_ownership_transfer() { + let env = Env::default(); + env.mock_all_auths(); + let (_, client, root) = setup_with_root(&env); + + let owner = Address::generate(&env); + let new_owner = Address::generate(&env); + let hash = commitment(&env, 42); + + client.register(&owner, &hash); + client.add_stellar_address(&owner, &hash, &owner); + + let signals = PublicSignals { + old_root: root, + new_root: BytesN::from_array(&env, &[43u8; 32]), + }; + + client.transfer(&owner, &hash, &new_owner, &dummy_proof(&env), &signals); + + let new_address = Address::generate(&env); + client.add_stellar_address(&new_owner, &hash, &new_address); + + let resolved = client.resolve_stellar(&hash); + assert_eq!(resolved, new_address); +} + +#[test] +fn test_add_stellar_address_overwrites_previous() { + let env = Env::default(); + env.mock_all_auths(); + let (_, client) = setup(&env); + + let owner = Address::generate(&env); + let hash = commitment(&env, 43); + + client.register(&owner, &hash); + + let original_address = Address::generate(&env); + let updated_address = Address::generate(&env); + + client.add_stellar_address(&owner, &hash, &original_address); + client.add_stellar_address(&owner, &hash, &updated_address); + + let resolved = client.resolve_stellar(&hash); + assert_eq!(resolved, updated_address); +} + #[test] #[should_panic(expected = "Error(Contract, #1)")] fn test_resolve_stellar_not_found_for_unregistered_hash() {