Marketplace Contract Documentation
Overview
The Marketplace contract is the DEUSS marketplace entrypoint for negotiated bond-token sales. It supports three sale modes on the same contract:
MARKETPLACE: direct offer acceptance with optional counteroffersREDEMPTION: direct offer acceptance restricted to an offer-specific allowlist, intended for issuer or operator buybacksINTEREST_DISCOVERY: threshold-gated book-building where investors first express binding interest and only later activate that interest into normal deals
The contract manages offers, interests, deals, disputes, and offer-level escrow accounting. Inventory is escrowed through EscrowManager during registerOffer(...), asset validation is delegated to AssetManager, and eligibility checks are delegated to EntityRegistry through EntityEligibilityGuard.
Prerequisites
- Contract must be initialized with:
owner_offerExpiryThresholdmaxOfferLifetimemarketplacePaymentExpiryThresholdredemptionPaymentExpiryThresholdinterestDiscoveryPaymentExpiryThresholddisputeBufferPeriodassetManager_escrowManagerentityRegistry_maxCounterOffers
- Proper roles must be assigned to authorized users:
PAYMENT_HANDLER: For handling paymentsARBITRATOR: For resolving disputesADMIN: For managing platform settings (dependency wiring, configuration parameters, currency and entity-type allowlists)SEIZURE_ROLE(_ROLE_3): For seizing escrowed assets from frozen offers and deals; bootstrap grants this toTimelockController, not the operational adminFREEZE_ROLE(_ROLE_4): For freezing and unfreezing offers and dealsINTEREST_DISCOVERY_OPERATOR(_ROLE_5): Operational maintenance; can callcloseExpiredInterestsindependent of the offer owner's enablement status and can cancel expired failedINTEREST_DISCOVERYoffers while the owner account remains enabled; withdrawal still routes to the escrow depositor
- Required registry contracts must be properly configured:
- Asset Manager
- Escrow Manager
- Entity Registry
- Allowed pricing currencies must be configured through
addCurrency(bytes3 currency). Marketplace currency checks use exactbytes3allowlist matching; charset validation of currency symbols is not enforced inMarketplaceBase. - Admins must set allowed entity types through:
setAllowedEntityType(typeId, allowed)setAllowedEntityTypes(typeIds, allowed)
- Counter offer settings:
- Maximum counter offers per user per offer (default: 5, configurable by ADMIN)
- Deployment timing defaults from
DeployConstants.sol:- Direct
MARKETPLACEpayment expiry: 4 days / 96 hours REDEMPTIONpayment expiry: 5 days / 120 hoursINTEREST_DISCOVERYpayment expiry: 4 days / 96 hours, snapshotted into each new interest-discovery offer- Minimum offer and counter-offer lifetime: 1 day / 24 hours
- Dispute buffer period: 3 days / 72 hours
- Direct
- Dispute buffer settings:
- Configurable by ADMIN via
setDisputeBufferPeriod(uint256 disputeBufferPeriod) - Bounds:
MIN_DISPUTE_BUFFER_PERIOD = 1 day,MAX_DISPUTE_BUFFER_PERIOD = 10 days - Reverts when out of bounds:
Marketplace__DisputeBufferPeriodTooLow(uint256 disputeBufferPeriod)Marketplace__DisputeBufferPeriodTooHigh(uint256 disputeBufferPeriod)
- Configurable by ADMIN via
Entity Type Allowlist and Wallet Validation
Marketplacerequires a non-zeroEntityRegistryduring initialization.Marketplaceenforces wallet/entity enabled state and entity type allowlist on entrypoints that create new offers, new direct-sale deals, new counter-offer proposals, or new expressed-interest commitments:registerOffer: seller (caller)acceptOffer: buyer (caller)expressInterest: investor (caller)createCounterOffer: buyer (caller)
activateInterestis a lifecycle operation for an already expressed interest. It does not re-check the investor's entity type allowlist status; it re-checks only that the stored investor wallet/entity and seller wallet/entity are enabled.resolveCounterOfferre-checks the stored buyer's entity type only whenaccepted == true.- Lifecycle operations that do not create or advance a deal commitment, such as declining or cancelling a counter offer, do not re-check entity type allowlist.
seizeOfferEscrowandseizeDealre-check only that the beneficiary wallet/entity is enabled. They do not require the beneficiary's entity type to be allowlisted for normal marketplace participation, allowing compliance recovery to enabled operational or custody entity types.- Actor-identity authority is distinct from protocol-role authority:
- When authority comes from a stored business actor address, the caller must equal that actor and the actor must be enabled in
EntityRegistry. - This rule applies to direct-sale
cancelOffer,withdrawAvailable,cancelCounterOffer,resolveCounterOffer,initiateDispute, and the owner branches of interest-discoverycancelOfferandcloseExpiredInterests. - The
INTEREST_DISCOVERY_OPERATORbranches of failed-book interest-discoverycancelOfferandcloseExpiredInterestsare role-based and do not require the caller to be the offer owner.ADMINalone does not grant this right. - Operator-authorized failed-book
cancelOfferalso requires the offer owner to remain enabled because cancellation withdraws escrow back to that owner.
- When authority comes from a stored business actor address, the caller must equal that actor and the actor must be enabled in
Marketplacevalidates entity wallet/entity enabled state on creation and deal-advancement paths:registerOffer: seller (caller)acceptOffer: buyer (caller) and seller (offer owner)expressInterest: investor (caller) and seller (offer owner)activateInterest: stored investor and seller (offer owner)createCounterOffer: buyer (caller) and seller (offer owner)resolveCounterOfferwhenaccepted == true: seller (caller) and buyer (deal buyer)settleDealwhen the deal isPAID: buyer (deal buyer)
- Additional wallet check is not performed in:
resolvePaymentresolvePaymentsresolveDispute
- Escrow releases still execute token transfers and follow the policy of the escrowed asset:
cancelOfferandwithdrawAvailableultimately triggerEscrowManager.withdrawsettleDealin thePAIDbranch ultimately triggersEscrowManager.claim
- Operational consequence: some lifecycle actions can become blocked after creation if the eventual payout recipient is later disabled and the escrowed asset enforces registry-aware transfer rules.
- Direct-sale
cancelOffer, all interest-discoverycancelOfferbranches,withdrawAvailable,resolveCounterOffer, and the owner branch ofcloseExpiredInterestsare blocked at authorization time when the offer owner is disabled; theINTEREST_DISCOVERY_OPERATORbranch ofcloseExpiredInterestsis not blocked by owner authorization because it does not transfer escrow. - Buyer-authorized
cancelCounterOfferandinitiateDisputeare blocked at authorization time when the deal buyer is disabled. settleDealin thePAIDbranch re-checks that the deal buyer is enabled before paying the deal buyer.- If asset-level transfer policy rejects a recipient at escrow release time, the assets stay in escrow until the recipient is re-enabled or governance uses freeze + seizure.
- Direct-sale
Contract Architecture
The Marketplace is the central component of the bond marketplace system:
- Inherits from
MarketplaceBasefor base functionality - Implements
IMarketplaceinterface - Implements
IMarketplaceLensSourceas a compact read surface for the lens contract - Manages offers, interests, and deals
- Integrates with EscrowManager for token handling
- Implements validation and verification mechanisms
- Supports three sale flows per offer through
SaleMode - Tracks interest-discovery-only state separately in
InterestDiscoveryState - Exposes grouped read snapshots, while the end-user getter API lives in
MarketplaceLens - Stores mutable marketplace state through ERC-7201 namespaced storage, with
Config, dependency addresses, counters, primary records, authorization mappings, and derived indexes grouped by the repository storage-layout convention. - Ordinary top-level storage variables still follow standard upgrade-safe ordering rules: append only in reserved gap space, shrink the gap accordingly, and never reorder or insert before existing top-level fields.
Direct-Sale Allowlist Model
MARKETPLACEandREDEMPTIONbelong to the same direct-sale function family:acceptOffercancelOfferwithdrawAvailable
allowedBuyersis stored per offer and exposed throughMarketplaceLens.getAllowedBuyers(uint256 offerId).MARKETPLACEsemantics:- empty
allowedBuyersmeans any eligible taker may callacceptOfferorcreateCounterOffer - non-empty
allowedBuyersmeans only listed accounts may callacceptOfferorcreateCounterOffer - counter offers are supported only for
MARKETPLACEoffers whenallowCounterOffers == true
- empty
REDEMPTIONsemantics:allowedBuyersmust be non-empty- only listed accounts may call
acceptOffer allowCounterOffersmust befalse; redemption offers cannot use counter offers
INTEREST_DISCOVERYdoes not supportallowedBuyers.
Pricing Currency Model
Marketplacecurrency validation applies to the offer price/payment metadata stored on the offer and emitted in marketplace events, not to the bond's denomination currency inBondRegistry.- A bond may be denominated in one allowed issuance currency while an offer uses another marketplace-supported pricing currency.
MarketplaceandBondRegistrykeep separate currency allowlists because issuance policy and marketplace settlement policy may be governed or migrated independently. Deployments should coordinate both lists for the intended market; an EUR-only market should configure both lists accordingly.
Marketplace State Model
InterestDiscoveryState.minSaleUnits: activation threshold for the offerInterestDiscoveryState.interestedUnits: cumulative expressed-interest volumeInterestDiscoveryState.reservedInterestUnits: inventory currently reserved by open expressed interestsInterestDiscoveryState.paymentExpiryThreshold: per-offer activation offset and activated-deal payment window snapshotted when an interest-discovery offer is registered- Threshold is derived from
interestedUnits >= minSaleUnits; there is no storedthresholdReachedflag - There is no stored offer-level interest-discovery lifecycle status
- A successful interest-discovery offer does not later enter the
CANCELLEDflag state simply because all interests were activated or closed and all available inventory was withdrawn. Integrators should derive completion from the offer amounts and interest-discovery state.
Configuration Snapshot Semantics
- Economic offer terms are fixed when an offer is registered: token, token ID, amount, lot size, unit price, currency, sale mode, expiry, and direct-sale allowlist.
offerExpiryThresholdandmaxOfferLifetimeare live validation settings for new offers and new counter offers. Existing offers and existing proposed counter offers keep their stored expiry timestamps.marketplacePaymentExpiryThreshold,redemptionPaymentExpiryThreshold,disputeBufferPeriod, andmaxCounterOffersPerUserare live operational settings. Direct-sale deals, accepted counter offers, and new counter-offer attempts use the values in force when that downstream action is executed.interestDiscoveryPaymentExpiryThresholdis different: it is snapshotted into each newINTEREST_DISCOVERYoffer at registration. It defines the activation deadline fromsaleEndand the payment window used when expressed interests are activated into deals.
Core Functions
registerOffer(OfferInput calldata offer)
Registers a new offer for bond trading.
Prerequisites:
- Offer must be valid for the selected
saleMode - Sender wallet/entity must be enabled and sender entity type must be allowlisted
- Sender must approve
EscrowManagerfor the token contract before callingregisterOffer - Asset must be accepted by
AssetManager.validateAsset(...)
Parameters:
offer: The offer input containing asset metadata, amounts, price, mode, expiry, and optional direct-sale allowlistallowCounterOffers: Boolean flag indicating whether counter offers are allowed for this specific offerallowedBuyers: Optional direct-sale taker allowlistsaleMode:MARKETPLACE,REDEMPTION, orINTEREST_DISCOVERYminSaleUnits: minimum threshold for interest-discovery offers
Returns:
uint256: The unique identifier for the registered offer
Events:
OfferRegistered(offerId, owner, tokenAddress, tokenId, assetType, saleMode): Offer identity and asset referenceOfferTermsRegistered(offerId, totalAmount, lot, unitPrice, currency, expiry, allowCounterOffers): Immutable offer pricing and sale termsInterestDiscoveryConfigured(offerId, minSaleUnits): Emitted only for interest-discovery offers. The currentinterestDiscoveryPaymentExpiryThresholdis snapshotted into the offer's interest-discovery state at the same time.AmountsUpdated(offerId, available, inDeals, sold): Emitted when amounts are updated
Errors:
- EntityEligibilityGuard__EntityWalletNotAllowed(address wallet): Sender wallet/entity is disabled.
- EntityEligibilityGuard__EntityTypeNotAllowed(uint256 typeId): Sender entity type is not allowlisted.
- Marketplace__AssetManagerNotSet(): Asset manager dependency is not configured.
- Marketplace__ZeroAmount():
totalAmountorlotequals 0. - Marketplace__LotSizeTooLarge():
lotis greater thantotalAmount. - Marketplace__TotalAmountNotMultipleOfLot():
totalAmount % lot != 0. - Marketplace__ZeroPrice():
unitPriceequals 0. - Marketplace__InvalidCurrency(bytes3 currency): Currency is not in the allowed set.
- Marketplace__InvalidSaleMode(uint8 saleMode):
saleModeis not one of the supported enum encodings. - Marketplace__InvalidExpiry(uint256 expiry, uint256 timestamp):
expiryis in the past or its lifetime is belowofferExpiryThreshold. - Marketplace__ExpiryTooFar(uint256 expiry, uint256 timestamp, uint256 maxOfferLifetime):
expiry - block.timestampexceedsmaxOfferLifetime. - Marketplace__CounterOffersMustBeDisabled():
allowCounterOffers == truefor any non-marketplace offer. - Marketplace__AllowedBuyersNotAllowed():
allowedBuyers.length != 0for an interest-discovery offer. - Marketplace__AllowedBuyersRequired():
allowedBuyers.length == 0for a redemption offer. - Marketplace__DuplicateAllowedBuyer(address buyer): the same buyer appears more than once in
allowedBuyers. - ZeroAddress(): one of the entries in
allowedBuyersis the zero address. - Marketplace__MinSaleUnitsNotAllowed(uint256 minSaleUnits):
minSaleUnits != 0for a direct-sale offer. - Marketplace__InvalidMinSaleUnits(uint256 minSaleUnits, uint256 totalAmount, uint256 lot): invalid threshold configuration for an interest-discovery offer.
Important Notes:
- Proper validation of input data is performed including asset support, amounts, price, currency, expiry, and sale mode
saleModeis validated against the supported enum encodings before mode-specific offer rules are applied- Lot Size: The
lotparameter is mandatory and must satisfy:lot > 0(cannot be zero)lot ≤ totalAmount(cannot exceed total amount)totalAmount % lot == 0(total amount must be a multiple of lot size)
- Offer Expiry: The
expirytimestamp must create a lifetime in the inclusive range[offerExpiryThreshold, maxOfferLifetime]from the current block timestamp - Escrow Creation: Tokens are immediately transferred to escrow upon offer registration, not when offers are accepted
- Wallet Validation Path:
registerOfferperforms seller wallet/entity enabled validation and entity-type allowlist validation inMarketplacebefore callingEscrowManager.createEscrow - Asset Validation Timing:
AssetManageris consulted only while registering the offer. Existing offers keep their escrowed inventory and are not revalidated if an admin later disables the token or tokenId inAssetManager. - Escrow ID Tracking:
EscrowManager.createEscrow(...)returns an internalescrowIdthat is stored inMarketplaceas_escrowIdByOfferId[offerId] - Escrow ID Invariant:
registerOffercapturesEscrowManager.previewNextEscrowId()before creating escrow and reverts unless the returnedescrowIdmatches that previewed value. The escrow ID is not required to equal the marketplaceofferId - Interest-Discovery Offer Rules:
allowCounterOffersmust befalseallowedBuyersmust be emptyminSaleUnitsmust be non-zero, no greater thantotalAmount, and a multiple oflot- initializes
InterestDiscoveryStatefor the offer, including the snapshottedpaymentExpiryThreshold
- Direct-Sale Offer Rules:
minSaleUnitsmust be0MARKETPLACEmay leaveallowedBuyersempty or restrict acceptance to a provided allowlistMARKETPLACEmay enable or disable counter offers per offerREDEMPTIONmust provide at least one allowed buyer and must disable counter offers
Sequence Diagram:
acceptOffer(uint256 offerId, uint256 amount)
Accepts an offer by creating a deal. On deal creation, the offer amounts are updated:
inDealsamount is increased by the amount input.availableamount is decreased by the amount input.
Prerequisites:
- Offer must not be expired
- Offer must not be cancelled
- Offer must be in a direct-sale mode (
MARKETPLACEorREDEMPTION) - If
saleMode == MARKETPLACEandallowedBuyersis non-empty, buyer must be allowlisted - If
saleMode == REDEMPTION, buyer must be allowlisted - Offer must not be frozen
- Amount must be valid (greater than 0, multiple of lot size, within available amount)
- Buyer entity type must be allowlisted
- Buyer and seller wallets/entities must be enabled
- Seller wallet/entity enabled state and entity type were validated when the offer was originally registered; seller wallet/entity enabled state is rechecked here
Parameters:
offerId: The ID of the offer to acceptamount: The amount of tokens to accept (must be greater than 0 and a multiple of offer's lot size)
Returns:
uint256: The unique identifier for the created deal
Events:
DealCreated(offerId, dealId, buyer, dealType, amount, price)withdealType == DealType.OFFER: Direct-sale deal identity and amount snapshotDealTermsRegistered(dealId, paymentDeadline, counterOfferExpiry, disputeDeadline): Deal payment and deadline termsAmountsUpdated(offerId, available, inDeals, sold): Emitted when amounts are updated
Errors:
- EntityEligibilityGuard__EntityWalletNotAllowed(address wallet): Buyer or seller wallet/entity is disabled.
- EntityEligibilityGuard__EntityTypeNotAllowed(uint256 typeId): Buyer entity type is not allowlisted.
- Marketplace__NotMarketplaceOffer(uint256 offerId): Offer is not in the direct-sale family.
- Marketplace__BuyerNotAllowed(uint256 offerId, address buyer): Buyer is not permitted to directly accept the offer.
- Marketplace__OfferExpired(uint256 expiry, uint256 timestamp): Offer has expired.
- Marketplace__OfferAlreadyCancelled(uint256 offerId): Offer has already been cancelled.
- Marketplace__OfferFrozen(uint256 offerId): Offer is frozen.
- Marketplace__SenderIsOwner(): Buyer equals seller.
- Marketplace__ZeroAmount():
amount == 0. - Marketplace__InsufficientAvailableAmount(uint256 requested, uint256 available):
amountexceeds available. - Marketplace__AmountNotMultipleOfLot(uint256 lot, uint256 amount):
amount % lot != 0.
Important Notes:
- Validates buyer wallet/entity enabled state and type allowlist, and validates seller wallet/entity enabled state before creating a new deal
- For
MARKETPLACE, direct acceptance is public only whenallowedBuyersis empty - For
REDEMPTION, direct acceptance is always restricted to the stored allowlist - Creates a PENDING deal that requires payment within the deadline
- Deal Expiry: Payment deadline is calculated from the sale mode:
MARKETPLACE:marketplacePaymentExpiryThreshold + block.timestampREDEMPTION:redemptionPaymentExpiryThreshold + block.timestamp
- Lot Size Validation: The
amountparameter must be a multiple of the offer'slotsize. If the offer has no lot size (lot = 0), the transaction will revert during offer registration - Concurrency Handling: The system uses atomic state updates to prevent overselling:
- Amount validation occurs before state changes
- Available amount is decremented immediately after validation
- If multiple users try to accept the same offer simultaneously, only those with sufficient available amount will succeed (first come first serve)
- Escrow Architecture: No new escrow is created during
acceptOffer- tokens are already held in escrow from the original offer registration. The escrow is managed at the offer level, not the deal level
Sequence Diagram:
expressInterest(uint256 offerId, uint256 amount)
Creates a binding pre-payment interest for an INTEREST_DISCOVERY offer.
Prerequisites:
- Offer must be in
INTEREST_DISCOVERYmode - Offer must not be expired
- Offer must not be cancelled
- Offer must not be frozen
- Caller must not be the offer owner
- Amount must be valid (greater than 0, multiple of lot size, within available amount)
- Investor entity type must be allowlisted
- Seller wallet/entity must be enabled
Parameters:
offerId: The ID of the offer to reserve inventory fromamount: The amount of tokens to reserve
Returns:
uint256: The unique identifier for the expressed interest
Events:
InterestExpressed(offerId, interestId, investor, amount, price)AmountsUpdated(offerId, available, inDeals, sold)
Errors:
- Marketplace__NotInterestDiscoveryOffer(uint256 offerId): Offer is not an interest-discovery offer.
- EntityEligibilityGuard__EntityWalletNotAllowed(address wallet): Seller wallet/entity is disabled.
- EntityEligibilityGuard__EntityTypeNotAllowed(uint256 typeId): Investor entity type is not allowlisted.
- Marketplace__OfferExpired(uint256 expiry, uint256 timestamp): Offer has expired.
- Marketplace__OfferAlreadyCancelled(uint256 offerId): Offer has already been cancelled.
- Marketplace__OfferFrozen(uint256 offerId): Offer is frozen.
- Marketplace__SenderIsOwner(): Investor equals seller.
- Marketplace__ZeroAmount():
amount == 0. - Marketplace__InsufficientAvailableAmount(uint256 requested, uint256 available):
amountexceeds available. - Marketplace__AmountNotMultipleOfLot(uint256 lot, uint256 amount):
amount % lot != 0.
Important Notes:
- No deal is created yet
Offer.amounts.availableis decreased immediatelyinterestedUnitsis cumulative and is never decrementedreservedInterestUnitstracks currently reserved, still-open expressed interests- Entity-type eligibility is accepted at expression time; later allowlist changes do not invalidate already expressed interests
- There is no stored
thresholdReachedflag; threshold is derived frominterestedUnits >= minSaleUnits
activateInterest(uint256 interestId)
Activates a previously expressed interest into a normal PENDING deal.
Prerequisites:
- Interest must exist
- Linked offer must be in
INTEREST_DISCOVERYmode - Linked offer must not be cancelled
- Linked offer must not be frozen
- Threshold must be reached
- Interest status must be
EXPRESSED - Activation must happen on or before
saleEnd + paymentExpiryThreshold, wherepaymentExpiryThresholdis snapshotted on the linked interest-discovery offer - Activation may happen before
saleEndonce the threshold has been reached - Stored investor wallet/entity must be enabled
- Seller wallet/entity must be enabled
Parameters:
interestId: The ID of the expressed interest to activate
Returns:
uint256: The unique identifier for the created deal
Events:
InterestStatusUpdated(interestId, offerId, newStatus, oldStatus)DealCreated(offerId, dealId, buyer, dealType, amount, price)DealTermsRegistered(dealId, paymentDeadline, counterOfferExpiry, disputeDeadline)AmountsUpdated(offerId, available, inDeals, sold)
Errors:
- Marketplace__InterestNotFound(uint256 interestId): Interest does not exist.
- Marketplace__NotInterestDiscoveryOffer(uint256 offerId): Linked offer is not an interest-discovery offer.
- Marketplace__OfferAlreadyCancelled(uint256 offerId): Linked offer has already been cancelled.
- Marketplace__OfferFrozen(uint256 offerId): Linked offer is frozen.
- Marketplace__ThresholdNotReached(uint256 offerId, uint256 interestedUnits, uint256 minSaleUnits): Cumulative expressed interest is still below threshold.
- Marketplace__InvalidInterestStatus(uint256 interestId, uint8 status): Interest is not in
EXPRESSEDstatus. - Marketplace__InterestActivationWindowExpired(uint256 offerId, uint256 activationDeadline, uint256 timestamp): Activation deadline has already passed.
- Marketplace__InterestActivationDeadlineOverflow(uint256 offerId, uint256 saleEnd, uint256 paymentExpiryThreshold):
saleEnd + paymentExpiryThresholdcannot be computed safely. - EntityEligibilityGuard__EntityWalletNotAllowed(address wallet): Stored investor or seller wallet/entity is not enabled.
Important Notes:
- Activation is allowed once
minSaleUnitshas been reached, including beforesaleEnd, and remains available untilsaleEnd + paymentExpiryThreshold - Any caller can activate an expressed interest. This is intentional because the investor commitment is created by
expressInterest; activation always converts the storedinterest.investorinto the deal buyer and never usesmsg.senderas buyer. - The permissionless activation path allows operator or keeper automation to progress successful books without requiring every investor to submit a second transaction.
- Investors may continue expressing interest until
saleEndeven if earlier interests have already been activated - The created deal buyer is always
interest.investor, notmsg.sender Offer.amounts.inDealsincreases andreservedInterestUnitsdecreases during activation- Payment deadline uses the value snapshotted on the offer: activations through
saleEndusesaleEnd + paymentExpiryThreshold, while activations aftersaleEnduseblock.timestamp + paymentExpiryThreshold - Activation deadline calculation is guarded against arithmetic overflow as defence in depth.
closeExpiredInterests(uint256 offerId, uint256[] calldata interestIds)
Closes stale expressed interests in bounded batches after the activation deadline has passed.
Prerequisites:
- Offer must be in
INTEREST_DISCOVERYmode - Offer must not be cancelled
- Caller must be the enabled offer owner or hold the
INTEREST_DISCOVERY_OPERATORrole- If caller is the offer owner, the owner account must be enabled in
EntityRegistry;INTEREST_DISCOVERY_OPERATORrole path is unaffected by owner enablement status ADMINrole alone does not authorize this call
- If caller is the offer owner, the owner account must be enabled in
- Threshold must have been reached
block.timestamp > saleEnd + paymentExpiryThreshold, wherepaymentExpiryThresholdis snapshotted on the linked interest-discovery offer- Each provided interest must belong to the offer and still be in
EXPRESSEDstatus
Parameters:
offerId: The linked interest-discovery offerinterestIds: The specific interest identifiers to close
Events:
InterestStatusUpdated(interestId, offerId, newStatus, oldStatus)for each closed interestAmountsUpdated(offerId, available, inDeals, sold)
Errors:
- Marketplace__NotAuthorized(address caller): Caller is neither the enabled offer owner nor holds
INTEREST_DISCOVERY_OPERATOR; or caller is the offer owner but the owner account is disabled inEntityRegistry. - Marketplace__OfferAlreadyCancelled(uint256 offerId): Offer has already been cancelled.
- Marketplace__NotInterestDiscoveryOffer(uint256 offerId): Offer is not an interest-discovery offer.
- Marketplace__InterestActivationWindowOpen(uint256 offerId, uint256 activationDeadline, uint256 timestamp): Activation deadline has not passed yet.
- Marketplace__InterestActivationDeadlineOverflow(uint256 offerId, uint256 saleEnd, uint256 paymentExpiryThreshold):
saleEnd + paymentExpiryThresholdcannot be computed safely. - Marketplace__ThresholdNotReached(uint256 offerId, uint256 interestedUnits, uint256 minSaleUnits): Threshold was never reached.
- Marketplace__InterestOfferMismatch(uint256 interestId, uint256 offerId): Interest does not belong to the supplied offer.
- Marketplace__InvalidInterestStatus(uint256 interestId, uint8 status): Interest is not in
EXPRESSEDstatus.
Important Notes:
- This is the bounded cleanup path for successful books
- Failed books whose threshold was never reached use
cancelOffer, which releases all remaining available and reserved inventory at the offer level instead of closing individual interests. releasedAmountis returned toOffer.amounts.available- The function can be called repeatedly in batches
- The function remains callable while the offer is frozen; freeze does not pause the activation-window lifecycle
- The function is blocked on cancelled offers (for example after
seizeOfferEscrow) because the offer-level reserved bucket may already have been released or seized
createCounterOffer(CounterOfferInput calldata counterOffer)
Creates a counter offer to an existing offer (technically as a deal in PROPOSED status and type COUNTER_OFFER).
⚠️ NOTE: No amounts in the offer are being updated yet!
Prerequisites:
- Original offer must not be expired
- Original offer must not be cancelled
- Original offer must not be frozen
- Original offer must be in
MARKETPLACEmode - Original offer must allow counter offers (
allowCounterOffers = true) - If
allowedBuyersis non-empty, buyer must be allowlisted - Buyer entity type must be allowlisted
- Buyer and seller wallets/entities must be enabled
- Seller wallet/entity enabled state and entity type were validated when the offer was originally registered; seller wallet/entity enabled state is rechecked here
- Sender must not have reached the platform-wide counter offer limit for this offer
- Amount must be valid (greater than 0, multiple of lot size, within available amount)
- Counter offer unit price must be non-zero
- Counter offer expiry must satisfy the configured lifetime window:
counterOffer.expiry >= block.timestamp + offerExpiryThresholdcounterOffer.expiry <= block.timestamp + maxOfferLifetimeofferExpiryThresholditself must stay within [MIN_OFFER_EXPIRY_THRESHOLD,MAX_OFFER_EXPIRY_THRESHOLD]maxOfferLifetimemust stay withinofferExpiryThreshold <= maxOfferLifetime <= MAX_CONFIGURABLE_OFFER_LIFETIME
Parameters:
counterOffer: The counter offer input containing offer ID, amount, price, and expiry
Returns:
uint256: The unique identifier for the counter offer deal
Events:
DealCreated(offerId, dealId, buyer, dealType, amount, price)withdealType == DealType.COUNTER_OFFERDealTermsRegistered(dealId, paymentDeadline, counterOfferExpiry, disputeDeadline)(paymentDeadlineanddisputeDeadlineare 0 while the deal remainsPROPOSED)CounterOfferCreated(offerId, dealId, owner): Emitted when counter offer is created
Errors:
- Marketplace__CounterOffersNotAllowed(): The original offer does not allow counter offers (
allowCounterOffers = false). - Marketplace__CounterOfferLimitReached(): Sender has reached the maximum number of counter offers allowed per user for this offer.
- EntityEligibilityGuard__EntityWalletNotAllowed(address wallet): Buyer or seller wallet/entity is disabled.
- EntityEligibilityGuard__EntityTypeNotAllowed(uint256 typeId): Buyer entity type is not allowlisted.
- Marketplace__NotMarketplaceOffer(uint256 offerId): Offer is not a
MARKETPLACEoffer. - Marketplace__BuyerNotAllowed(uint256 offerId, address buyer): Buyer is not permitted to enter the direct-sale flow for this offer.
- Marketplace__OfferExpired(uint256 expiry, uint256 timestamp): Original offer has expired.
- Marketplace__OfferAlreadyCancelled(uint256 offerId): Original offer has already been cancelled.
- Marketplace__OfferFrozen(uint256 offerId): Original offer is frozen.
- Marketplace__InvalidExpiry(uint256 expiry, uint256 timestamp): Counter offer expiry is in the past or below the configured minimum lifetime.
- Marketplace__ExpiryTooFar(uint256 expiry, uint256 timestamp, uint256 maxOfferLifetime): Counter offer lifetime exceeds
maxOfferLifetime. - Marketplace__ZeroAmount():
amount == 0. - Marketplace__ZeroPrice():
unitPrice == 0. - Marketplace__InsufficientAvailableAmount(uint256 requested, uint256 available):
amountexceeds available. - Marketplace__AmountNotMultipleOfLot(uint256 lot, uint256 amount):
amount % lot != 0.
Important Notes:
- Creates a PROPOSED deal that the original offer owner can accept or decline
- Counter offers are intentionally marketplace-only.
REDEMPTIONoffers are allowlist-only direct acceptances and must be registered withallowCounterOffers == false - Validates buyer wallet/entity enabled state and type allowlist, and validates seller wallet/entity enabled state before creating a new counter offer
allowedBuyersgates entry into the full direct-sale flow, not only direct acceptance- Counter Offer Permissions: Each offer has an
allowCounterOffersflag set during registration. Iffalse, no counter offers can be created for that offer - Counter Offer Limits: The platform enforces a maximum number of counter offers per user per offer (configurable by admin, default is 5)
- Counter Tracking: Each successful counter offer increments the user's counter for that specific offer. Counters are independent across different users and different offers.
- Counter-offer counters are intentionally not decremented when proposed counter offers expire, are cancelled, or are declined. The per-user quota is a permanent business-limit counter for the offer; only changing the global admin-configured cap changes future capacity.
- The counter offer limit check occurs after the permission check, ensuring proper validation order
Marketplace__CounterOfferExpired(...)applies when checking an already created counter offer (e.g.,cancelCounterOffer/resolveCounterOffer), not duringcreateCounterOffer
Sequence Diagram:
cancelCounterOffer(uint256 dealId)
Cancels a counter offer.
Prerequisites:
- Linked offer must be in
MARKETPLACEmode - Deal must be in
PROPOSEDstatus (counter offer) - Neither the counter offer nor its parent offer may be frozen
- Counter offer must still be valid (i.e., not expired)
- Only the creator of the counter offer can cancel it, and the creator account must be enabled in
EntityRegistry
Parameters:
dealId: ID of the counter offer deal to cancel
Events:
DealStatusUpdated(dealId, offerId, newStatus, oldStatus): Emitted when deal status is updatedCounterOfferCancelled(dealId, cancelor): Emitted when counter offer is cancelled
Errors:
- Marketplace__InvalidStatus(uint8 actualStatus): Deal is not in
PROPOSEDstatus. - Marketplace__DealFrozen(uint256 dealId): Counter offer deal is frozen.
- Marketplace__OfferFrozen(uint256 offerId): Parent offer is frozen.
- Marketplace__CounterOfferExpired(uint256 expiry, uint256 timestamp): Counter offer has already expired.
- Marketplace__NotAuthorized(address caller): Caller is not the counter offer creator (
deal.buyer), ordeal.buyeris disabled inEntityRegistry.
Important Notes:
- Offer Validity: "Offer is valid" means the counter offer deal has not yet expired (
block.timestamp <= deal.counterOfferExpiry) - Cancellation Rules: Only the enabled counter offer creator (deal.buyer) can cancel, and only while the counter offer remains valid. Expired counter offers cannot be cancelled.
- Freeze Interaction: Freeze blocks cancellation, but it does not pause
counterOfferExpiry. A counter offer can expire while frozen and stay expired after unfreeze.
Sequence Diagram:
resolveCounterOffer(uint256 dealId, bool accepted)
Resolves a counter offer by accepting (accepted==true) or declining (accepted==false) it.
Prerequisites:
- Deal must be in PROPOSED status (counter offer)
- Neither the counter offer nor its parent offer may be frozen
- Counter offer must not be expired (current timestamp must be ≤ counter offer expiry)
- Only the original offer owner can resolve, and the offer owner account must be enabled in
EntityRegistry - Related offer must not be cancelled
- If accepted, original offer must not be expired
- If accepted, amount must be greater than 0 and sufficient amount must be available
- If accepted, seller and buyer wallets/entities must be enabled
- If accepted, buyer entity type must still be allowlisted
Parameters:
dealId: ID of the counter offer deal to resolveaccepted: Whether to accept the counter offer
Events:
DealStatusUpdated(dealId, offerId, newStatus, oldStatus): Emitted when deal status is updatedCounterOfferResolved(dealId, accepted, tokenId, amount, currency, buyer, seller): Emitted when counter offer is resolved (payload matches the deal and underlying offer for integrators)AmountsUpdated(offerId, available, inDeals, sold): Emitted when amounts are updated (if accepted)
Errors:
- Marketplace__InvalidStatus(uint8 actualStatus): Deal is not in
PROPOSEDstatus. - Marketplace__DealFrozen(uint256 dealId): Counter offer deal is frozen.
- Marketplace__OfferFrozen(uint256 offerId): Parent offer is frozen.
- Marketplace__NotAuthorized(address caller): Caller is not the original offer owner, or the offer owner account is disabled in
EntityRegistry. - Marketplace__CounterOfferExpired(uint256 expiry, uint256 timestamp): Counter offer has expired (current timestamp > counter offer expiry).
- Marketplace__OfferExpired(uint256 expiry, uint256 timestamp): On accept, original offer has expired.
- Marketplace__OfferAlreadyCancelled(uint256 offerId): Related offer has already been cancelled.
- EntityEligibilityGuard__EntityWalletNotAllowed(address wallet): On accept, seller or buyer wallet/entity is disabled.
- EntityEligibilityGuard__EntityTypeNotAllowed(uint256 typeId): On accept, buyer entity type is not allowlisted.
- Marketplace__ZeroAmount(): On accept,
amount == 0. - Marketplace__InsufficientAvailableAmount(uint256 requested, uint256 available): On accept,
amountexceeds available. - Marketplace__AmountNotMultipleOfLot(uint256 lot, uint256 amount): On accept,
amount % lot != 0.
Important Notes:
- Counter Offer Expiry Validation: The function validates that the counter offer has not expired before allowing resolution. This ensures that expired counter offers cannot be accepted or declined through this function
- Freeze Interaction: Freeze blocks resolution, but it does not pause
counterOfferExpiry. A frozen counter offer may still expire and remain expired after unfreeze. - Accepted Path Eligibility Validation: Seller wallet/entity-enabled and buyer wallet/entity-enabled plus buyer entity-type allowlist checks are performed only when
accepted == true - Payment Deadline: When a counter offer is accepted, the deal's
paymentDeadlineis set toblock.timestamp + marketplacePaymentExpiryThreshold, establishing the deadline for payment - Dispute Buffer: When accepted, the
disputeBufferis set topaymentDeadline + disputeBufferPeriod, defining the window for dispute initiation after an unpaid deal - Counter Offer Expiry Reset: The
counterOfferExpiryfield is reset to 0 for both accepted and declined counter offers, as it's no longer relevant once resolved - Deal Type Change: When accepted, the deal type changes from
COUNTER_OFFERtoOFFER, and the status changes toPENDING - Original Offer Expiry: Accepting a counter offer validates that the original offer has not expired. Declining a counter offer remains available after the original offer expires while the counter offer itself is unexpired.
- validateDealStatus: This function checks that
deal.status == DealStatus.PROPOSED. It ensures the deal is a counter offer (only counter offers can have PROPOSED status) and hasn't been previously resolved
Sequence Diagram:
resolvePayment(uint256 dealId, bool paid)
Resolves payment for a pending deal as either paid or unpaid.
Prerequisites:
paymentDeadlinefor aPENDINGdeal is set when the deal is created:block.timestamp + marketplacePaymentExpiryThresholdfor direct marketplace deals and accepted counter offersblock.timestamp + redemptionPaymentExpiryThresholdfor redemption deals- For activated interests,
saleEnd + paymentExpiryThresholdwhen activated throughsaleEnd, otherwiseblock.timestamp + paymentExpiryThreshold, using the value snapshotted on the interest-discovery offer
- All payment-expiry thresholds are admin-configurable and must stay within
[
MIN_PAYMENT_EXPIRY_THRESHOLD,MAX_PAYMENT_EXPIRY_THRESHOLD] - If
paid == true:- Caller must have
PAYMENT_HANDLERrole - Deal must be in
PENDINGor pre-arbitrationUNPAIDstatus - If the deal is currently
PENDING,block.timestamp <= deal.paymentDeadline - If the deal is currently
UNPAID,deal.disputeBuffer != 0(the dispute outcome has not already been arbitrated)
- Caller must have
- If
paid == false:- Callable by anyone
- Deal must be in
PENDINGstatus block.timestamp > deal.paymentDeadline
Parameters:
dealId: The ID of the deal to resolvepaid:trueto resolve asPAID,falseto resolve asUNPAID
Events:
DealStatusUpdated(dealId, offerId, newStatus, oldStatus): Emitted when status changes toPAIDorUNPAIDPaymentResolved(dealId, paid, resolver): Emitted when payment resolution is finalized
Errors:
- Reverts if caller lacks the PAYMENT_HANDLER role when
paid == true. - Marketplace__InvalidStatus(uint8 actualStatus):
paid == true: Deal is neitherPENDINGnorUNPAIDpaid == false: Deal is notPENDING
- Marketplace__PaymentDeadlineExpired():
paid == trueand deadline already passed. - Marketplace__DealNotExpired():
paid == falseand deadline has not passed yet. - Marketplace__DealAlreadyArbitrated():
paid == true, deal is currentlyUNPAID, anddisputeBuffer == 0.
Important Notes:
- The function remains callable while the deal or its parent offer is frozen. Only asset movement is blocked by the freeze.
_resolvePaymentrecords payment intent (status transition only); no assets are transferred at this step. Asset movement is gated in_settleDeal, which calls_validateDealOrOfferNotFrozenbefore any transfer. - Freeze does not pause
paymentDeadline; once the raw deadline passes,paid == truestill reverts withMarketplace__PaymentDeadlineExpired() resolvePayment(dealId, true)can correct a pre-arbitrationUNPAIDdeal back toPAID, but not after arbitration has finalized the dispute outcome- A status transition while frozen does not reduce the compliance team's ability to seize.
seizeDealcovers all pre-settlement statuses (PENDING,UNPAID,IN_DISPUTE, andPAID), so the deal remains subject to seizure regardless of payment resolution.
Sequence Diagram:
resolvePayments(PaymentResolutionInput[] calldata resolutions)
Resolves multiple deal payments in input order. Each item has a dealId and paid flag and uses the same validation rules as resolvePayment. Item-level failures are skipped and reported through events, so later items can continue.
Prerequisites:
- Empty batches are valid.
- If any item has
paid == true, caller must have thePAYMENT_HANDLERrole before any item is processed. - If every item has
paid == false, the batch is permissionless.
Parameters:
resolutions: Ordered payment-resolution items:dealId: The ID of the deal to resolve.paid:trueto resolve asPAID,falseto resolve asUNPAID.
Events:
- Successful items emit the same
DealStatusUpdatedandPaymentResolvedevents asresolvePayment. PaymentResolutionSkipped(dealId, paid, resolver, failureData): Emitted for each skipped item.PaymentResolutionsBatchProcessed(resolver, requested, succeeded, skipped): Emitted once after the batch finishes, including for empty batches.
Errors and Skips:
- A missing
PAYMENT_HANDLERrole for a batch containing any paid item reverts the whole call before processing starts. - Per-item validation failures do not revert the batch. The item is skipped and the raw revert data is emitted in
PaymentResolutionSkipped. - Skipped items can include the same errors as
resolvePayment, such as:- Marketplace__InvalidStatus(uint8 actualStatus)
- Marketplace__PaymentDeadlineExpired()
- Marketplace__DealNotExpired()
- Marketplace__DealAlreadyArbitrated()
Important Notes:
- The function remains callable while the deal or its parent offer is frozen.
- Successful and failed duplicate deal IDs are processed strictly in input order.
- Earlier successful items remain applied if a later item fails.
resolvePaymentsdoes not return per-item results. Off-chain callers should inspectPaymentResolutionSkippedandPaymentResolutionsBatchProcessedlogs for failure details and summary counts.
Sequence Diagram:
cancelOffer(uint256 offerId)
Cancels a direct-sale offer or an expired failed interest-discovery offer.
Cancellation means that:
_offerCancelled[offerId]is set totrueoffer.expiryis set toblock.timestampoffer.amounts.availableis set to0- for direct-sale offers, if
available > 0, that amount is withdrawn from offer escrow immediately - for direct-sale offers, if
available == 0andinDeals > 0, cancellation is still allowed - for failed interest-discovery offers,
reservedInterestUnitsis also set to0andavailable + reservedInterestUnitsis withdrawn from offer escrow immediately
Prerequisites:
- For direct-sale offers, caller must be the offer owner, and the offer owner account must be enabled in
EntityRegistry. - For interest-discovery offers, caller must be either:
- the offer owner, with the owner account enabled in
EntityRegistry - an address holding
INTEREST_DISCOVERY_OPERATOR, while the offer owner account is enabled inEntityRegistry
- the offer owner, with the owner account enabled in
- Offer must not be already cancelled.
- Offer must not be frozen.
- For direct-sale offers, offer must be in
MARKETPLACEorREDEMPTIONmode and at least one of these must hold:offer.amounts.available > 0offer.amounts.inDeals > 0
- For interest-discovery offers:
block.timestamp > offer.expiryinterestedUnits < minSaleUnits
Parameters:
offerId: ID of the offer to cancel
Events:
OfferCancelled(offerId, sender): Emitted when offer is cancelledAmountsUpdated(offerId, available, inDeals, sold): Emitted when amounts are updated
Errors:
- Marketplace__NotAuthorized(address caller): For direct-sale offers, caller is not the offer owner or the offer owner account is disabled in
EntityRegistry. For interest-discovery offers, caller is the offer owner but the owner account is disabled inEntityRegistry, or caller isINTEREST_DISCOVERY_OPERATORwhile the owner account is disabled. - Ownable.Unauthorized(): For interest-discovery offers, caller is neither the enabled offer owner nor holds
INTEREST_DISCOVERY_OPERATOR. - Marketplace__OfferAlreadyCancelled(uint256 offerId): Offer has already been cancelled.
- Marketplace__OfferFrozen(uint256 offerId): Offer is frozen.
- Marketplace__NotMarketplaceOffer(uint256 offerId): Offer is neither a direct-sale offer nor an interest-discovery offer handled by this path.
- Marketplace__NoAvailableAmount(uint256 offerId): Direct-sale offer has no
availableamount and noinDealsamount. - Marketplace__OfferNotExpired(uint256 expiry, uint256 timestamp): Interest-discovery offer has not expired yet.
- Marketplace__ThresholdAlreadyReached(uint256 offerId, uint256 interestedUnits, uint256 minSaleUnits): Interest-discovery offer reached its activation threshold and must use the successful-book lifecycle.
- Token__TransferNotAllowed(address from, address to, uint256 tokenId) or token-specific transfer errors: The cancellation was authorized, but asset-level transfer policy rejected the escrow withdrawal recipient for a reason other than Marketplace owner enablement.
Important Notes:
- Failed-book cancellation is the canonical closeout path for expired
INTEREST_DISCOVERYoffers whose threshold was not met. - It releases the full remaining escrow balance represented by direct
availableinventory plus still-reserved expressed interests. - It does not individually rewrite every expressed interest in storage.
MarketplaceLens.getInterest(...)projects affected interests as closed after the reserved bucket is released. INTEREST_DISCOVERY_OPERATORcan perform failed-book closeout when the offer owner is unavailable, but escrow withdrawal still pays the original escrow depositor and requires that owner to remain enabled. If the depositor is disabled, re-enable the depositor and retry, or usesetOfferFrozen(...)+seizeOfferEscrow(...)to redirect through the privileged recovery path to an enabled beneficiary.
withdrawAvailable(uint256 offerId)
Withdraws currently withdrawable inventory from offer escrow. This is used for cancelled direct-sale offers and for leftover inventory from successful interest-discovery books.
Prerequisites:
- Caller must be the offer owner, and the offer owner account must be enabled in
EntityRegistry. - Offer must not be frozen.
- Mode-specific rules:
MARKETPLACE: Offer must already be cancelled.INTEREST_DISCOVERY: Offer must not be cancelled,block.timestamp > offer.expiry, andinterestedUnits >= minSaleUnits.
Parameters:
offerId: ID of the offer
Events:
AmountsUpdated(offerId, available, inDeals, sold): Emitted when available is withdrawn and set to zero
Errors:
- Marketplace__OfferNotCancelled(uint256 offerId): Offer has not been cancelled yet.
- Marketplace__OfferAlreadyCancelled(uint256 offerId): Interest-discovery offer has already been cancelled.
- Marketplace__NotAuthorized(address caller): Caller is not the offer owner, or the offer owner account is disabled in
EntityRegistry. - Marketplace__OfferFrozen(uint256 offerId): Offer is frozen.
- Marketplace__OfferNotExpired(uint256 expiry, uint256 timestamp): Interest-discovery offer has not expired yet.
- Marketplace__ThresholdNotReached(uint256 offerId, uint256 interestedUnits, uint256 minSaleUnits): Interest-discovery threshold was not reached; use
cancelOfferfor failed-book closeout. - Marketplace__NoAvailableAmount(uint256 offerId): No available amount exists to withdraw.
Important Notes:
- For
MARKETPLACE, onlyoffer.amounts.availableis withdrawable. - For
INTEREST_DISCOVERYfailed books,withdrawAvailableis intentionally blocked. UsecancelOffer, which withdraws bothavailableandreservedInterestUnitsin one step and marks the offer cancelled. - For
INTEREST_DISCOVERYsuccessful books, only currently unreservedavailableamount is withdrawable. - Unactivated successful-book reservations remain protected until the activation deadline. After the deadline passes,
closeExpiredInterestscan release them back toavailable, and the owner can then callwithdrawAvailable. - Withdrawal pays the fixed escrow depositor / offer owner through
EscrowManager.withdraw. If that wallet becomes disabled inEntityRegistryafter the offer was created, withdrawal reverts at token-transfer time and the escrow remains locked until the wallet is re-enabled or the offer is frozen and seized.
initiateDispute(uint256 dealId)
Initiates a dispute for an unpaid deal within the dispute period.
Prerequisites:
- Deal must be in UNPAID status
- Only the buyer of the deal can initiate a dispute, and the buyer account must be enabled in
EntityRegistry - Current timestamp must be within the dispute buffer period
Parameters:
dealId: ID of the deal in dispute
Events:
DealStatusUpdated(dealId, offerId, newStatus, oldStatus): Emitted when deal status is updated to IN_DISPUTE
Errors:
- Marketplace__NotAuthorized(address caller): Caller is not the buyer of the deal, or the buyer account is disabled in
EntityRegistry. - Marketplace__InvalidStatus(uint8 actualStatus): Deal is not in
UNPAIDstatus. - Marketplace__DisputePeriodExpired(): Current timestamp exceeds
deal.disputeBuffer.
Important Notes:
- The function remains callable while the deal or its parent offer is frozen
- Freeze does not pause
disputeBuffer; once the raw dispute window passes, the dispute can no longer be initiated
Sequence Diagram:
resolveDispute(uint256 dealId, DealStatus status)
Resolves a dispute for a deal by an arbitrator.
Prerequisites:
- Deal must be in IN_DISPUTE status
- Only users with ARBITRATOR role can call this function
- Status must be either PAID or UNPAID
Parameters:
dealId: The ID of the deal to resolvestatus: The new status for the deal (PAID or UNPAID)
Events:
DealStatusUpdated(dealId, offerId, newStatus, oldStatus): Emitted when deal status is updated
Errors:
- Reverts if caller lacks the ARBITRATOR role.
- Marketplace__DealNotInDispute(): Deal is not in
IN_DISPUTEstatus. - Marketplace__InvalidStatus(uint8 status): Provided
statusis notPAIDorUNPAID.
Important Notes:
- Only addresses with ARBITRATOR role can call this function
- The function remains callable while the deal or its parent offer is frozen
- Reverts if the deal is not in IN_DISPUTE status or if the new status is invalid
- Prevents future disputes by setting dispute buffer to 0
- Sets
disputeBufferto 0 to prevent the deal from being disputed again (griefing protection) - Updates deal status to the specified value
Sequence Diagram:
settleDeal(uint256 dealId)
Settles a deal after payment resolution.
Prerequisites:
- Neither the deal nor its parent offer may be frozen
- Deal must be in a valid status (PAID or UNPAID)
- If UNPAID, dispute period must have expired
- If PAID, the deal buyer must still be enabled in
EntityRegistry
Parameters:
dealId: The ID of the deal to settle
Events:
DealStatusUpdated(dealId, offerId, newStatus, oldStatus): Emitted when deal status is updated toSUCCESSFULforPAIDdeals orUNSUCCESSFULforUNPAIDdealsAmountsUpdated(offerId, available, inDeals, sold): Emitted when amounts are updated
Errors:
- Marketplace__DealFrozen(uint256 dealId): Deal is frozen.
- Marketplace__OfferFrozen(uint256 offerId): Parent offer is frozen.
- Marketplace__InvalidStatus(uint8 actualStatus): Deal status is not
PAIDorUNPAID. - Marketplace__DisputePeriodNotExpired(): For
UNPAIDdeals, dispute period has not yet expired. - Marketplace__NotAuthorized(address caller): For
PAIDdeals, the deal buyer is disabled inEntityRegistry.
Important Notes:
- Can be called by anyone once the deal is in PAID or UNPAID status
- For UNPAID deals, requires dispute period to have expired
- Transfers tokens to buyer if PAID, restores seller availability if UNPAID
- Changes deal status to
SUCCESSFULifPAIDorUNSUCCESSFULifUNPAID, and updates offer amounts - For PAID deals: Marketplace re-checks that the buyer is enabled, then transfers tokens to the buyer via EscrowManager
- For UNPAID deals: Tokens are returned to the seller's available amount and the deal ends as
UNSUCCESSFUL - Updates offer amounts: decreases inDeals, increases either sold (if PAID) or available (if UNPAID)
- Settlement does not choose a new recipient dynamically.
PAID: escrow is claimed todeal.buyerUNPAID: inventory is restored tooffer.amounts.available, but later withdrawal still paysoffer.owner
- If the paid buyer is disabled in
EntityRegistryat settlement time, the normal path reverts before state changes or escrow release. Recovery then requires either re-enabling the buyer or using freeze + seizure.
Sequence Diagram:
settleDeals(uint256[] calldata dealIds)
Attempts to settle multiple deals in one transaction. Each item is isolated with an internal self-call, so one reverting deal does not revert the whole batch.
Prerequisites:
- Each successful item must satisfy the same rules as
settleDeal(uint256 dealId). - Failed items are skipped and reported through events.
Parameters:
dealIds: Ordered list of deal IDs to attempt.
Events:
DealSettlementSkipped(dealId, settler, failureData): Emitted for each item that reverts.DealSettlementsBatchProcessed(settler, requested, succeeded, skipped): Emitted once after the batch finishes.- Successful items emit the same
DealStatusUpdatedandAmountsUpdatedevents assettleDeal.
Important Notes:
- The function is best suited for operational cleanup where some deals may already be settled, frozen, still pending, or otherwise not settleable.
- Ordering is preserved. Duplicate settleable deal IDs settle only once; later duplicates are skipped because the first successful item changes the deal status to
SUCCESSFULorUNSUCCESSFUL. - The helper
settleDealBatchItem(uint256 dealId)is callable only by the contract itself and exists to provide try/catch isolation. - A disabled paid buyer or failed asset transfer, including a blacklistable ERC20 rejecting the recipient, skips only that deal in
settleDeals; later items continue. Single-itemsettleDealstill reverts for that item, so the blast radius is localized to the affected deal. - Permanently stuck deals should be resolved operationally by re-enabling the recipient where appropriate or by using the freeze and seizure flow.
Administrative Functions
initialize(
address owner_, uint256 offerExpiryThreshold, uint256 maxOfferLifetime, uint256 marketplacePaymentExpiryThreshold, uint256 redemptionPaymentExpiryThreshold, uint256 interestDiscoveryPaymentExpiryThreshold, uint256 disputeBufferPeriod, address assetManager_, address escrowManager, address entityRegistry_, uint256 maxCounterOffers )
Initializes the contract with required parameters.
Prerequisites:
- Contract must not be already initialized
- All addresses must be valid
Parameters:
owner_: Address that will own the contractofferExpiryThreshold: Minimum future offset for new offers and counter offersmaxOfferLifetime: Maximum lifetime accepted for new offers and counter offersmarketplacePaymentExpiryThreshold: Payment deadline window for marketplace dealsredemptionPaymentExpiryThreshold: Payment deadline window for redemption dealsinterestDiscoveryPaymentExpiryThreshold: Activation offset and activated-deal payment window snapshotted into new interest-discovery offersdisputeBufferPeriod: Dispute window after payment deadlineassetManager_: Address of the asset managerescrowManager: Address of the escrow managerentityRegistry_: Non-zero address of the entity registrymaxCounterOffers: Maximum number of counter offers a user can make per offer (platform-wide limit)
Errors:
- Reverts if the contract is already initialized.
Marketplace__OfferExpiryThresholdTooLow(uint256 offerExpiryThreshold):offerExpiryThreshold < MIN_OFFER_EXPIRY_THRESHOLD.Marketplace__OfferExpiryThresholdTooHigh(uint256 offerExpiryThreshold):offerExpiryThreshold > MAX_OFFER_EXPIRY_THRESHOLD.Marketplace__MaxOfferLifetimeTooHigh(uint256 maxOfferLifetime):maxOfferLifetime > MAX_CONFIGURABLE_OFFER_LIFETIME.Marketplace__MaxOfferLifetimeBelowOfferExpiryThreshold(uint256 maxOfferLifetime, uint256 offerExpiryThreshold):maxOfferLifetime < offerExpiryThreshold.Marketplace__MaxCounterOffersPerUserZero():maxCounterOffers == 0.ZeroAddress()ifentityRegistry_is zero.- May revert if provided addresses are invalid per base initializer checks.
Sequence Diagram:
setAssetManager(address assetManager) / setEscrowManager(address escrowManager) / setEntityRegistry(address entityRegistry)
Sets protocol dependency addresses used by Marketplace.
Prerequisites:
- Caller must have
ADMINrole - Address must be non-zero
Errors:
ZeroAddress()DependenciesBase__EscrowManagerAlreadySet()DependenciesBase__EntityRegistryAlreadySet()
Important Notes:
setAssetManager(...)can update the configured asset manager.setEscrowManager(...)andsetEntityRegistry(...)are single-assignment inDependenciesBase.setEntityRegistry(...)is retained for shared dependency wiring, but initializedMarketplaceinstances already have this dependency set.
addCurrency(bytes3 currency) / removeCurrency(bytes3 currency)
Adds or removes supported pricing currencies.
Prerequisites:
- Caller must have
ADMINrole
Parameters:
currency: 3-byte currency code such asbytes3("EUR")
Important Notes:
- Marketplace validates offer currencies by exact
bytes3allowlist membership. addCurrency(bytes3)does not validate ISO 4217 character shape; admins should only add canonical uppercase currency codes.
Errors:
Marketplace__CurrencyAlreadyExists(bytes3 currency)Marketplace__CurrencyDoesNotExist(bytes3 currency)
setOfferExpiryThreshold(uint256 offerExpiryThreshold)
Sets the minimum future offset required for new offers and counter offers.
Prerequisites:
- Caller must have
ADMINrole
Errors:
Marketplace__OfferExpiryThresholdTooLow(uint256 offerExpiryThreshold)Marketplace__OfferExpiryThresholdTooHigh(uint256 offerExpiryThreshold)Marketplace__MaxOfferLifetimeBelowOfferExpiryThreshold(uint256 maxOfferLifetime, uint256 offerExpiryThreshold)
Important Notes:
- This setting controls the minimum required lifetime for subsequently registered offers and subsequently created counter offers.
- It cannot be raised above the current
maxOfferLifetime; admins must raisemaxOfferLifetimefirst if they want a larger minimum lifetime.
setMaxOfferLifetime(uint256 maxOfferLifetime)
Sets the maximum lifetime accepted for new offers and counter offers.
Prerequisites:
- Caller must have
ADMINrole
Events:
MaxOfferLifetimeSet(uint256 indexed maxOfferLifetime)
Errors:
Marketplace__MaxOfferLifetimeTooHigh(uint256 maxOfferLifetime)Marketplace__MaxOfferLifetimeBelowOfferExpiryThreshold(uint256 maxOfferLifetime, uint256 offerExpiryThreshold)
Important Notes:
- This setting controls the maximum accepted lifetime for subsequently registered offers and subsequently created counter offers.
- It must remain greater than or equal to the current
offerExpiryThreshold. - The hard cap is
MAX_CONFIGURABLE_OFFER_LIFETIME.
setMarketplacePaymentExpiryThreshold(uint256 marketplacePaymentExpiryThreshold) / setRedemptionPaymentExpiryThreshold(uint256 paymentExpiryThreshold) / setInterestDiscoveryPaymentExpiryThreshold(uint256 paymentExpiryThreshold)
Sets payment deadline windows used by the sale flows.
Prerequisites:
- Caller must have
ADMINrole
Errors:
Marketplace__PaymentExpiryThresholdTooLow(uint256 paymentExpiryThreshold)Marketplace__PaymentExpiryThresholdTooHigh(uint256 paymentExpiryThreshold)
Important Notes:
marketplacePaymentExpiryThresholdis used forMARKETPLACEacceptances and accepted counter offers.redemptionPaymentExpiryThresholdis used forREDEMPTIONacceptances. It is configured during initialization and can be updated independently.interestDiscoveryPaymentExpiryThresholdis snapshotted into each newINTEREST_DISCOVERYoffer. That per-offer value is added tosaleEndto derive the final activation deadline; activated interests use the same snapshot as their payment window, anchored tosaleEndwhen activated throughsaleEndand toblock.timestampwhen activated aftersaleEnd. Updating the global value affects subsequently registered offers, not existing interest-discovery offers.- Deployment defaults are 4 days for direct marketplace deals, 5 days for redemption deals, and 4 days for interest-discovery activation/payment windows.
- Offer lifetime bounds and payment expiry thresholds must stay within their configured minimum and maximum bounds.
setDisputeBufferPeriod(uint256 disputeBufferPeriod)
Sets the dispute window used after a deal becomes payable.
Prerequisites:
- Caller must have
ADMINrole
Events:
DisputeBufferPeriodSet(uint256 indexed disputeBufferPeriod)
Errors:
Marketplace__DisputeBufferPeriodTooLow(uint256 disputeBufferPeriod)Marketplace__DisputeBufferPeriodTooHigh(uint256 disputeBufferPeriod)
Important Notes:
- The deployment default dispute buffer is 3 days.
setAllowedEntityType(uint256 typeId, bool allowed) / setAllowedEntityTypes(uint256[] calldata typeIds, bool allowed)
Configures the entity-type allowlist used at selected entrypoints.
Prerequisites:
- Caller must have
ADMINrole
Important Notes:
- Changes affect future eligibility checks. Existing expressed interests are not invalidated by later entity-type allowlist changes; activation re-checks wallet/entity enabled state instead.
setMaxCounterOffersPerUser(uint256 maxCounterOffers)
Sets the maximum number of counter offers a user can make for a single offer.
Prerequisites:
- Caller must have the
ADMINrole
Parameters:
maxCounterOffers: The new maximum number of counter offers per user per offer
Errors:
- Reverts if caller lacks the ADMIN role.
Marketplace__MaxCounterOffersPerUserZero():maxCounterOffers == 0.
Important Notes:
- This is a platform-wide setting that applies to all offers
- The value must be greater than 0 so counter offers cannot be disabled through this limit
- Does not affect counter offers already created
- Changes take effect immediately for new counter offers
View Functions
Marketplace now exposes a compact read surface intended for MarketplaceLens. The user-facing convenience getters are documented in MarketplaceLens.md.
getOfferData(uint256 offerId)
Returns the grouped offer-side state required by the lens contract.
Parameters:
offerId: The ID of the offer to retrieve
Returns:
Offer: The complete offer structureInterestDiscoveryState: The interest-discovery-only state linked to the offer, including the snapshotted payment expiry thresholduint256[]: All interest identifiers linked to the offeraddress[]: Direct-sale allowlist linked to the offeruint256: Escrow ID allocated byEscrowManager
Errors:
- None.
Important Notes:
- This grouped getter exists to reduce
Marketplaceruntime bytecode while keeping the external read surface available throughMarketplaceLens - It intentionally excludes the simple offer flags because those flags are available through dedicated getters, keeping this grouped return value focused and low-stack
- For a marketplace offer, the returned
InterestDiscoveryStateis the zero-initialized default struct
getDealData(uint256 dealId)
Returns the grouped deal-side state required by the lens contract.
Parameters:
dealId: The ID of the deal to retrieve
Returns:
Deal: The complete deal structure containing all deal databool: Whether the deal is currently frozen
Errors:
- None.
getInterestData(uint256 interestId)
Returns the raw stored details of one interest.
Parameters:
interestId: The ID of the interest to retrieve
Returns:
Interest: The stored interest structure
Errors:
- Marketplace__InterestNotFound(uint256 interestId): Interest does not exist.
Important Notes:
- This function returns the stored state only
- The projected
CLOSEDview after failed-book cancellation is implemented inMarketplaceLens.getInterest(...), not inMarketplaceitself - The projected
SEIZEDview after offer-level reserved seizure is also implemented inMarketplaceLens.getInterest(...); raw storage may still showEXPRESSED
getOfferFlags(uint256 offerId)
Returns the compact offer-side flags required by the lens contract.
Parameters:
offerId: The ID of the offer to inspect
Returns:
bool: Whether the offer was explicitly cancelledbool: Whether the offer is currently frozenbool:trueifseizeOfferEscrow(...)consumed the reserved-interest bucket and set the raw offer-level flag
Errors:
- None.
Important Notes:
- This getter keeps flag-only reads out of
getOfferData(...)so the lens-source surface remains coverage-friendly - The reserved-interest marker is a raw offer-level value used by
MarketplaceLens.getInterest(...)for lazyEXPRESSED -> SEIZEDprojection - It does not rewrite individual
Interestrecords in storage - For offers where only
availablewas seized and no reserved bucket existed, the value remainsfalse
getConfigAndCounters() / isCurrencyAllowed() / getCounterOfferCount()
Returns current configuration, identifier counters, currency allowlist state, and per-user counter-offer counts inherited from MarketplaceBase.
getConfigAndCounters() returns a Config snapshot with:
offerExpiryThresholdmarketplacePaymentExpiryThresholdredemptionPaymentExpiryThresholdinterestDiscoveryPaymentExpiryThresholddisputeBufferPeriodmaxCounterOffersPerUsermaxOfferLifetime
It also returns the runtime offer, deal, and interest counters.
isCurrencyAllowed(currency) returns whether a bytes3 pricing currency is currently enabled.
getCounterOfferCount(user, offerId) returns the number of successful counter offers submitted by user for offerId. Counts do not decrement when counter offers are cancelled or declined.
Internal and Validation Functions
doAddressesMatch(address caller, address owner)
Validates that the caller matches an expected address without checking EntityRegistry.
This helper is reserved for non-business-actor checks such as the internal resolvePaymentBatchItem self-call. Business-actor identity checks use _requireEnabledActor(...) instead.
Parameters:
caller: Address of the transaction sender to validateowner_: Expected owner address
Returns:
- None
Errors:
- Marketplace__NotAuthorized(address caller):
caller != owner_.
Sequence Diagram:
_requireEnabledActor(address caller, address actor)
Validates that the caller matches a stored business actor address and that the actor is enabled in EntityRegistry.
Parameters:
caller: Address of the transaction sender to validateactor: Expected business actor address
Returns:
- None
Errors:
- Marketplace__NotAuthorized(address caller):
caller != actor, oractoris disabled inEntityRegistry.
validateOffer(OfferInput calldata offer, address owner) → Offer
Validates an incoming OfferInput and builds the immutable Offer struct used internally.
Parameters:
offer: Proposed offer inputowner_: Address to set as offer owner (must be enabled and have an allowlisted entity type if registry configured)
Returns:
Offer: Validated and normalized offer struct
Errors:
- EntityEligibilityGuard__EntityWalletNotAllowed(address wallet): Offer owner wallet/entity is disabled.
- EntityEligibilityGuard__EntityTypeNotAllowed(uint256 typeId): Offer owner entity type is not allowlisted.
- Marketplace__AssetManagerNotSet(): Asset manager dependency is missing.
- Marketplace__ZeroAmount():
totalAmount == 0orlot == 0. - Marketplace__LotSizeTooLarge():
lot > totalAmount. - Marketplace__TotalAmountNotMultipleOfLot():
totalAmount % lot != 0. - Marketplace__ZeroPrice():
unitPrice == 0. - Marketplace__InvalidCurrency(bytes3 currency): Currency not allowed.
- Marketplace__InvalidExpiry(uint256 expiry, uint256 timestamp):
expiryis in the past or its lifetime is belowofferExpiryThreshold. - Marketplace__ExpiryTooFar(uint256 expiry, uint256 timestamp, uint256 maxOfferLifetime):
expiry - block.timestampexceedsmaxOfferLifetime. - Marketplace__CounterOffersMustBeDisabled(): Invalid interest-discovery configuration.
- Marketplace__MinSaleUnitsNotAllowed(uint256 minSaleUnits): Invalid marketplace configuration.
- Marketplace__InvalidMinSaleUnits(uint256 minSaleUnits, uint256 totalAmount, uint256 lot): Invalid threshold configuration.
Sequence Diagram:
_validateOfferNotExpired(uint256 offerExpiry) / _validateCounterOfferNotExpired(uint256 counterOfferExpiry)
Ensures an offer or counter offer has not expired.
Parameters:
offerExpiry/counterOfferExpiry: Expiration timestamp to check
Errors:
- Marketplace__OfferExpired(uint256 expiry, uint256 timestamp): Current time past
offerExpiry. - Marketplace__CounterOfferExpired(uint256 expiry, uint256 timestamp): Current time past
counterOfferExpiry.
Sequence Diagram:
_validateOfferExpiryThreshold(uint256 offerExpiry)
Ensures a newly supplied offer or counter-offer expiry satisfies the configured lifetime bounds.
_validateDepositAmounts(uint256 total, uint256 lot)
Ensures total and lot sizes are non-zero, consistent, and divisible.
Parameters:
total: Total units to depositlot: Lot size per match
Errors:
- Marketplace__ZeroAmount():
total == 0orlot == 0. - Marketplace__LotSizeTooLarge():
lot > total. - Marketplace__TotalAmountNotMultipleOfLot():
total % lot != 0.
Sequence Diagram:
_validatePrice(uint256 price, bytes3 currency)
Checks price is positive and currency is supported.
Parameters:
price: Unit pricecurrency: 3-byte currency code
Errors:
- Marketplace__ZeroPrice():
price == 0. - Marketplace__InvalidCurrency(bytes3 currency): Currency not in allowed set.
Sequence Diagram:
_validateNonZeroPrice(uint256 price)
Checks price is non-zero.
Parameters:
price: Unit price
Errors:
- Marketplace__ZeroPrice():
price == 0.
Sequence Diagram:
_validateAsset(OfferInput calldata offer) → AssetType
Validates the offered asset through the configured AssetManager.
This validation is performed during offer registration. Trade execution paths for existing offers (acceptOffer, expressInterest, activateInterest, createCounterOffer, and accepted resolveCounterOffer) do not re-check AssetManager, so delisting an asset blocks new offers but does not by itself cancel, freeze, or make existing offers untradable.
Parameters:
offer: Offer input containing token metadata
Returns:
AssetType: The asset type resolved byAssetManager
Errors:
- Marketplace__AssetManagerNotSet(): Asset manager dependency is missing.
Sequence Diagram:
_getInterest(uint256 interestId) / _hasReachedThreshold(uint256 interestedUnits, uint256 minSaleUnits) / _validateThresholdReached(uint256 offerId, uint256 interestedUnits, uint256 minSaleUnits) / _getInterestActivationDeadline(uint256 offerId, uint256 saleEnd) / _getActivatedInterestPaymentDeadline(uint256 offerId, uint256 saleEnd) / _getInterestDiscoveryPaymentExpiryThreshold(uint256 offerId) / _validateInterestActivationWindow(uint256 offerId, uint256 saleEnd)
Interest-discovery-specific helpers used to:
- load and validate that an interest exists
- derive threshold reach from cumulative expressed interest
- enforce threshold-only activation / cleanup paths
- read the offer's snapshotted payment expiry threshold
- enforce the bounded activation deadline derived from
saleEndand the offer snapshot - reject activation-deadline overflow before comparing timestamps
- derive activated-interest payment deadlines from either
saleEndor the activation timestamp
_validateAmounts(Offer offer, uint256 amount)
Ensures the requested amount is greater than zero, available, and respects the lot granularity.
Parameters:
offer: The current offeramount: Requested amount for the deal
Errors:
- Marketplace__ZeroAmount():
amount == 0. - Marketplace__InsufficientAvailableAmount(uint256 requested, uint256 available):
amount > offer.amounts.available. - Marketplace__AmountNotMultipleOfLot(uint256 lot, uint256 amount):
amount % offer.lot != 0.
Sequence Diagram:
_validateDealStatus(DealStatus actual, DealStatus desired)
Ensures a deal has the expected status.
Parameters:
actual: Current statusdesired: Required status
Errors:
- Marketplace__InvalidStatus(uint8 actualStatus):
actual != desired.
Sequence Diagram:
Dispute Workflow
The dispute mechanism provides a way for buyers to challenge deals that have been marked as UNPAID when they believe payment was actually made:
- Deal Expiry: When a deal expires without payment confirmation, it can be marked as
UNPAIDusingresolvePayment(dealId, false) - Dispute Initiation: Buyer can call
initiateDispute()within the dispute buffer period: a. Deal status changes fromUNPAIDtoIN_DISPUTEb. Only the buyer who made the deal can initiate disputes c. Must be done withindisputeBuffertimeframe (calculated aspaymentDeadline + disputeBufferPeriod) - Dispute Resolution: An arbitrator with
ARBITRATORrole callsresolveDispute(): a. Arbitrator investigates the payment claim b. Sets final status to eitherPAID(buyer was correct) orUNPAID(seller was correct) c.disputeBufferis set to 0 to prevent re-disputing (griefing protection) - Settlement: After dispute resolution,
settleDeal()can be called: a. If resolved asPAID: Tokens are transferred to buyer and the deal ends asSUCCESSFULb. If resolved asUNPAID: Tokens return to seller's available amount and the deal ends asUNSUCCESSFUL - Griefing Protection: Once disputed and resolved, the deal cannot be disputed again due to
disputeBuffer = 0
Counter Offer Workflow
Counter offers provide a mechanism for buyers to propose different terms (price) for an existing offer:
-
Offer Configuration: Counter offers are available only for
MARKETPLACEoffers. When creating a marketplace offer, the seller specifies whether counter offers are allowed via theallowCounterOffersflagtrue: Counter offers are permitted for this offerfalse: Counter offers are blocked for this offerREDEMPTIONandINTEREST_DISCOVERYoffers must setallowCounterOfferstofalse
-
Creation: Buyer calls
createCounterOffer()with desired amount and price- Validation Process:
a. Checks if the original offer allows counter offers (
allowCounterOffers = true) b. Checks if the buyer has not exceeded the platform-wide counter offer limit for this specific offer c. Validates other standard requirements (authorization, non-zero price, amounts, expiry, etc.) - Counter Tracking: On success, the buyer's counter offer count for this offer is incremented
- Validation Process:
a. Checks if the original offer allows counter offers (
-
Status: Counter offer is created as a
DealwithPROPOSEDstatus andCOUNTER_OFFERtype -
Limits and Tracking:
- Platform-Wide Limit: Configurable maximum counter offers per user per offer (default: 5)
- Per-User, Per-Offer: Each user has an independent counter for each offer they interact with
- Persistence: Counter does not decrement when counter offers are cancelled or declined
- Admin Control: Administrators can adjust the platform-wide limit using
setMaxCounterOffersPerUser()
-
Cancellation: Counter offer with
PROPOSEDstatus can be cancelled by callingcancelCounterOffer():- only by its creator
- and only if the counter offer has not expired
-
Resolution: Original offer owner can invoke
resolveCounterOffer()to accept or decline the counter offer:- Expiry Validation: The counter offer must not be expired (current timestamp ≤ counter offer expiry)
- If accepted (
accepted = true):- Original offer must not be expired
- Deal status changes to
PENDING - Deal type changes to
OFFER paymentDeadlineis set toblock.timestamp + marketplacePaymentExpiryThresholddisputeBufferis set topaymentDeadline + disputeBufferPeriodcounterOfferExpiryis reset to 0- Offer amounts are updated (available decreases, inDeals increases)
- If declined (
accepted = false):- Deal status changes to
DECLINED counterOfferExpiryis reset to 0- Original offer expiry is not checked for declines
- Deal status changes to
Counter Offer Feature Summary
| Feature | Description | Scope |
|---|---|---|
| Mode Restriction | Counter offers only work for MARKETPLACE offers | Sale mode |
| Per-Offer Permission | allowCounterOffers flag in marketplace offer | Individual offer level |
| Platform-Wide Limit | Maximum counter offers per user per offer | Global setting (default: 5) |
| Counter Tracking | Tracks counter offer count per user per offer | Per-user, per-offer |
| Admin Control | setMaxCounterOffersPerUser() | ADMIN role required |
| View Functions | getConfigAndCounters(), getCounterOfferCount(), isCurrencyAllowed() | Public, read-only |
Example Scenario:
- Platform limit is set to 5 counter offers per user per offer
- Alice creates Offer A with
allowCounterOffers = true - Bob can make up to 5 counter offers on Offer A
- Bob's counter offers on Offer A don't affect his ability to make counter offers on other offers
- If Alice declines all of Bob's counter offers, Bob cannot make more (limit reached)
- If admin changes the platform limit to 10, Bob can now make 5 more counter offers on Offer A
Freeze Semantics
Freeze is a reversible, operational / custody hold operated by FREEZE_ROLE.
- Freeze does not pause time.
- Deadlines (
offer.expiry,paymentDeadline,disputeBuffer,counterOfferExpiry) continue to run normally while an offer or deal is frozen. They are never extended because of freeze. - Freeze blocks actions that move escrowed assets, create new commercial commitments against frozen inventory, or otherwise change deal-formation / finalization state.
- Time-based cleanup, freeze/unfreeze administration, and payment/dispute procedural actions remain callable while frozen.
Functions blocked by freeze
| Function | Blocked by | Reason |
|---|---|---|
acceptOffer | offer frozen | creates commercial commitment |
cancelOffer | offer frozen | moves escrowed assets |
withdrawAvailable | offer frozen | moves escrowed assets |
createCounterOffer | offer frozen | creates commercial commitment |
expressInterest | offer frozen | creates commercial reservation against inventory (independently also blocked on cancelled offers) |
activateInterest | parent offer frozen | creates a new deal (commercial commitment; independently also blocked on cancelled offers) |
cancelCounterOffer | deal or parent offer frozen | changes deal formation state |
resolveCounterOffer | deal or parent offer frozen | changes deal formation state |
settleDeal | deal or parent offer frozen | finalizes economic outcome / moves assets |
Functions that remain callable while frozen
| Function | Rationale |
|---|---|
resolvePayment | Payment workflow progresses against the raw stored deadline; freeze must not block it |
resolvePayments | Batched payment workflow uses the same raw-deadline semantics as resolvePayment |
initiateDispute | Dispute window runs against the raw disputeBuffer; freeze does not pause it |
resolveDispute | Arbitration is a procedural workflow that must continue regardless of frozen state |
closeExpiredInterests | Time-based cleanup; remains callable so long as the offer has not been cancelled |
Important:
closeExpiredInterestsis blocked on cancelled offers (e.g. afterseizeOfferEscrow). CallingseizeOfferEscrowcancels the offer and zeroes the offer-level reserved bucket in O(1). Remaining stored interests may still beEXPRESSEDin storage, but the lens projects them asSEIZEDbased on the parent offer state. Attempting to close them after cancellation is blocked to prevent underflow inreservedInterestUnits.
setOfferFrozen(uint256 offerId, bool frozen)
Freezes or unfreezes an offer. The offer must exist.
Prerequisites:
- Caller must have
FREEZE_ROLE offerIdmust reference an existing offer
Effects when frozen:
acceptOfferreverts withMarketplace__OfferFrozencancelOfferreverts withMarketplace__OfferFrozenwithdrawAvailablereverts withMarketplace__OfferFrozencreateCounterOfferreverts withMarketplace__OfferFrozencancelCounterOfferreverts withMarketplace__OfferFrozen(via deal-or-offer check)resolveCounterOfferreverts withMarketplace__OfferFrozen(via deal-or-offer check)settleDealreverts withMarketplace__OfferFrozen(via deal-or-offer check)expressInterestreverts withMarketplace__OfferFrozenactivateInterestreverts withMarketplace__OfferFrozenresolvePayment,resolvePayments,initiateDispute,resolveDispute,closeExpiredInterestsare not affected by offer freeze- Existing counter offers continue to age against their raw
counterOfferExpiry; unfreezing does not revive an already expired counter offer - Freeze alone does not unblock a disabled-recipient payout. It only prevents further normal progression and enables the seizure path to an enabled beneficiary.
Events:
OfferFrozenSet(uint256 indexed offerId, bool indexed frozen)
Errors:
Marketplace__OfferDoesNotExist(uint256 offerId): Offer was never registered
setDealFrozen(uint256 dealId, bool frozen)
Freezes or unfreezes a deal. The deal must exist.
Prerequisites:
- Caller must have
FREEZE_ROLE dealIdmust reference an existing deal
Effects when the deal is frozen (or when the parent offer is frozen):
cancelCounterOfferrevertsresolveCounterOfferrevertssettleDealrevertsresolvePayment,resolvePayments,initiateDispute,resolveDisputeare not blocked — they progress normally against raw stored deadlinescounterOfferExpiry,paymentDeadline, anddisputeBuffercontinue running normally while frozen- Freeze alone does not release escrow; it is only a prerequisite for
seizeDeal(...).
Events:
DealFrozenSet(uint256 indexed dealId, bool indexed frozen)
Errors:
Marketplace__DealDoesNotExist(uint256 dealId): Deal was never created
isOfferFrozen(uint256 offerId) / isDealFrozen(uint256 dealId)
Public view getters returning the current frozen state.
Seizure
Seizure is an irreversible operation that transfers escrowed tokens to a designated beneficiary. The seizure caller must hold SEIZURE_ROLE (_ROLE_3), and the target must be frozen first.
DealStatus.SEIZED
SEIZED is appended at index 10 to DealStatus. All existing numeric values (NON_EXISTING=0 through UNSUCCESSFUL=9) are unchanged.
InterestStatus.SEIZED
SEIZED is appended at index 4 to InterestStatus. All existing numeric values (NON_EXISTING=0 through CLOSED=3) are unchanged. Integrators observe this status through MarketplaceLens.getInterest(...) when an EXPRESSED interest loses its backing inventory because the parent offer's reserved bucket was seized at the offer level.
seizeOfferEscrow(uint256 offerId, address beneficiary, bytes32 reason)
Seizes the non-deal escrow balance of a frozen offer. For MARKETPLACE offers this is just offer.amounts.available. For INTEREST_DISCOVERY offers it includes both offer.amounts.available and the aggregated reservedInterestUnits bucket.
Prerequisites:
- Caller must have
SEIZURE_ROLE - Offer must exist (
_escrowIdByOfferId[offerId] != 0) - Offer must be frozen (
isOfferFrozen(offerId) == true) beneficiary != address(0)(reverts withErrors.ZeroAddress())reason != bytes32(0)(reverts withMarketplace__ZeroReason())- Beneficiary must be an enabled
EntityRegistryaccount - Either
offer.amounts.available > 0or, forINTEREST_DISCOVERY,reservedInterestUnits > 0
Side effects:
offer.amounts.availableset to 0- For
INTEREST_DISCOVERY,state.reservedInterestUnitsis zeroed in O(1) and the offer-level reserved-seizure flag is set _offerCancelled[offerId]set totrue;offer.expiryset toblock.timestampEscrowManager.claim(escrowId, seizedAmount, beneficiary)called- Individual
Interestrecords are not rewritten in storage inDeals,sold, andtotalare not modified
Events:
OfferCancelled(offerId, msg.sender)AmountsUpdated(offerId, 0, inDeals, sold)OfferEscrowSeized(offerId, beneficiary, seizedAmount, reason, caller)
seizeDeal(uint256 dealId, address beneficiary, bytes32 reason)
Seizes the escrowed tokens locked in a specific frozen deal.
Prerequisites:
- Caller must have
SEIZURE_ROLE - Deal must exist (
deal.status != NON_EXISTING) - Deal must be frozen (
isDealFrozen(dealId) == true) - Parent offer must be frozen (
isOfferFrozen(deal.offerId) == true) beneficiary != address(0)andreason != bytes32(0)- Beneficiary must be an enabled
EntityRegistryaccount deal.statusmust be one of:PENDING,UNPAID,IN_DISPUTE,PAID- All other statuses revert with
Marketplace__InvalidStatus
- All other statuses revert with
Side effects:
offer.amounts.inDealsdecremented bydeal.amountEscrowManager.claim(escrowId, deal.amount, beneficiary)calleddeal.statusset toDealStatus.SEIZEDavailable,sold, andtotalare not modified
Events:
DealStatusUpdated(dealId, offerId, newStatus, oldStatus)AmountsUpdated(offerId, available, inDeals - dealAmount, sold)DealSeized(dealId, offerId, beneficiary, amount, reason, caller)
Operational Notes
- Freeze is reversible; seizure is not.
seizeOfferEscrowalways marks the offer as cancelled.seizeDealdoes not auto-cancel the parent offer.- For
INTEREST_DISCOVERYoffers, offer-level seizure handles both freeavailableinventory and the aggregatedreservedInterestUnitsbucket in one call. - After
seizeOfferEscrow, remaining rawEXPRESSEDinterests are interpreted via the lens as effectivelySEIZED; this avoids an O(n) loop over all interest records during seizure. - After
seizeDealthe parent offer remains frozen; it must be explicitly unfrozen or fully cleared viaseizeOfferEscrow. - Freeze does not pause time. All deadlines run normally during freeze. Payment/dispute workflows continue against raw stored timestamps.
- For disabled-recipient deadlocks, the practical recovery options are:
- re-enable the original payout recipient in
EntityRegistry, then retry the blocked lifecycle action - or freeze and seize to an enabled beneficiary:
seizeOfferEscrow(...)for offer-owner withdrawal deadlockssetOfferFrozen(...)+setDealFrozen(...)+seizeDeal(...)for paid-deal buyer deadlocks
- re-enable the original payout recipient in