Dria

Swan
NFTHardhat
21,000 USDC
View results
Submission Details
Severity: medium
Invalid

Asset Purchase Race Condition Leading to Market State Corruption

Summary

The Swan protocol's purchase mechanism contains critical race conditions where asset status updates occur before transfers complete, allowing market manipulation and potential asset/payment desynchronization. This can lead to assets being marked as sold while remaining with the seller, or payments being processed without asset delivery.

The purchase flow in Swan.sol has multiple synchronization issues: https://github.com/Cyfrin/2024-10-swan-dria/blob/c8686b199daadcef3161980022e12b66a5304f8e/contracts/swan/Swan.sol#L276-L299

function purchase(address _asset) external {
// @Issue - No round/phase validation before state changes
AssetListing storage listing = listings[_asset];
// @Issue - Basic status check without temporal validation
if (listing.status != AssetStatus.Listed) {
revert InvalidStatus(listing.status, AssetStatus.Listed);
}
// @Issue - Premature status update creates race condition
listing.status = AssetStatus.Sold;
// @Issue - Non-atomic transfers can fail after status change
SwanAsset(_asset).transferFrom(listing.seller, address(this), 1);
SwanAsset(_asset).transferFrom(address(this), listing.buyer, 1);
// @Issue - Payment transfers not validated for success
token.transferFrom(listing.buyer, address(this), listing.price);
token.transfer(listing.seller, listing.price);
}

Vulnerability Details

The issue is the status is updated to Sold before the actual transfers occur, if either transfer fails, the asset remains with the seller but the status is incorrectly marked as Sold. This creates an inconsistent state where an asset is marked sold but ownership hasn't changed

function purchase(address _asset) external {
AssetListing storage listing = listings[_asset];
// @Issue - Status check doesn't verify if we're in the correct round
// This allows purchases in expired rounds
if (listing.status != AssetStatus.Listed) {
revert InvalidStatus(listing.status, AssetStatus.Listed);
}
// @Issue - No validation that buyer is still in Purchase phase
// Allows purchases outside of valid purchase window
if (listing.buyer != msg.sender) {
revert Unauthorized(msg.sender);
}
// @Issue - Status updated before transfers complete
// Creates race condition where status is Sold but transfers may fail
listing.status = AssetStatus.Sold;
// @Issue - No checks if transfers will succeed
// Missing checks for approvals and ownership
SwanAsset(_asset).transferFrom(listing.seller, address(this), 1);
SwanAsset(_asset).transferFrom(address(this), listing.buyer, 1);
// @Issue - No validation of token transfer success
// Token transfers could fail after asset transfers complete
token.transferFrom(listing.buyer, address(this), listing.price);
token.transfer(listing.seller, listing.price);
}

This is problem because assets can be purchased in expired rounds, breaking the round-based market design. Purchases can occur outside designated purchase phases, disrupting market timing controls and status updates before transfers create inconsistent states if transfers fail.

POC

// 1. Create listing in round N
// 2. Wait for round N+1
// 3. Execute purchase:
await swan.purchase(assetAddress);
// 4. Asset transfers fail but status is already Sold
// 5. Asset becomes locked in invalid state

Impact

  • Assets can become permanently locked in "Sold" state without ownership transfer

  • Payments may process without asset delivery

  • Market round integrity compromised through out-of-phase purchases

  • Potential for double-spending through failed transfer recovery attempts

Recommendations

function purchase(address _asset) external {
AssetListing storage listing = listings[_asset];
+ // Validate round and phase
+ (uint256 currentRound, BuyerAgent.Phase phase,) = BuyerAgent(listing.buyer).getRoundPhase();
+ require(currentRound == listing.round && phase == BuyerAgent.Phase.Buy, "Invalid purchase timing");
+ // Check approvals and balances
+ require(SwanAsset(_asset).isApprovedForAll(listing.seller, address(this)), "Asset not approved");
+ require(token.allowance(msg.sender, address(this)) >= listing.price, "Insufficient allowance");
// Execute transfers before status update
+ bool success = true;
+ success &= SwanAsset(_asset).transferFrom(listing.seller, address(this), 1);
+ success &= SwanAsset(_asset).transferFrom(address(this), listing.buyer, 1);
+ success &= token.transferFrom(listing.buyer, address(this), listing.price);
+ success &= token.transfer(listing.seller, listing.price);
+ require(success, "Transfer failed");
// Update status only after successful transfers
listing.status = AssetStatus.Sold;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 12 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.