diff --git a/docs/wiki/Rentals.md b/docs/wiki/Rentals.md index f30b247..98bfb09 100644 --- a/docs/wiki/Rentals.md +++ b/docs/wiki/Rentals.md @@ -1,19 +1,23 @@ # Rentals -AtomicMarket V2 supports **custodial per-hour rentals** built on the AtomicAssets V2 -holdership system: while an asset is rented, the renter is its **holder** (visible to games -and dApps through the AtomicAssets `holders` table) while the **ownership** stays with the -market contract, so the renter can never run away with the asset. +AtomicMarket supports **non-custodial per-hour rentals**. While an asset is rented, the renter +becomes the **real AtomicAssets owner** of it, so unmodified games, APIs and dApps grant the renter +the asset's utility with no special integration. The lister's right to get the asset back is parked +in the AtomicAssets `leases` table, the **single source of truth for the lock state**. The asset is +locked from transfer, burn and offer-out for the duration, and a permissionless `reclaim` force-returns +it to the lister at expiry. The asset is **never escrowed** in the market contract. ## Lifecycle ``` -announcerent ──> transfer (memo "rental") ──> rentasset ──> [extensions] ──> endrent ──┐ - │ (custody) (rented) (listed again)│ - └──────────────────────────── cancelrent (asset returned) <────────────────────────┘ +announcerent ──> rentasset ──> [extensions] ──> expiry ──> endrent / reclaim ──┐ + │ (renter = owner, locked) (returned, listed again)│ + ├── editrent (reprice / re-bound, any time) │ + └── cancelrent (delist, any time; reclaim-if-expired) <────────────────────────┘ ``` -1. **List** — the owner announces the listing with its terms: +1. **List** — the owner announces the listing with its terms. The asset stays in the owner's account; + announcing does not move or lock it. ```sh cleos push action atomicmarket announcerent '{ @@ -26,18 +30,15 @@ announcerent ──> transfer (memo "rental") ──> rentasset ──> [extensi }' -p alice@active ``` - - `price_per_hour` is denoted in the listing symbol. If it differs from - `settlement_symbol`, a delphi pair must be configured and the rental is paid in the - settlement symbol at the oracle rate at renting time (e.g. price in USD, paid in WAX). - - `maximum_rental_duration` (seconds) is the longest period one rental — including - extensions by the same renter — can cover. Minimum 3600 (one hour), maximum 28 days - (2,419,200 seconds). + - `price_per_hour` is denoted in the listing symbol. If it differs from `settlement_symbol`, a + delphi pair must be configured and the rental is paid in the settlement symbol at the oracle + rate at renting time (e.g. price in USD, paid in WAX). + - `maximum_rental_duration` (seconds) is the longest period one rental — including extensions by + the same renter, measured from the lease's original start — can cover. Minimum 3600 (one hour), + maximum 28 days (2,419,200 seconds). AtomicAssets enforces its own 28-day cap independently. + - Each asset is its own listing (one listing per asset id). -2. **Activate** — the owner transfers the asset to the market account with the memo - `rental`. The contract becomes the custodial owner. Each asset is its own listing - (one listing per asset id). - -3. **Rent** — a renter pays from their deposited balance: +2. **Rent** — a renter pays from their deposited balance: ```sh cleos push action atomicmarket rentasset '{ @@ -50,62 +51,85 @@ announcerent ──> transfer (memo "rental") ──> rentasset ──> [extensi }' -p bob@active ``` - - The total price (`price_per_hour × hours`, oracle-converted if applicable) is deducted - from the renter's balance and paid out like a sale: marketplace fees, the collection - fee (with [royalty splits](Royalty-Splits) and - [collection fee](V2-Changes)), remainder to the listing owner. - - The AtomicAssets holdership of the asset moves to the renter until `rental_end`. - - `expected_price_per_hour` protects the renter against listing changes between signing - and execution. - -4. **Extend** — the *current* renter can call `rentasset` again while their rental is - active; the purchased hours are appended to the current period. The combined remaining - period must stay within `maximum_rental_duration`. Rentals never renew automatically. - -5. **Wrap up** — after `rental_end`, **anyone** may call `endrent(asset_id)`. It moves the - holdership back to the market contract, making the listing rentable again. If nobody - calls it, the next `rentasset` moves holdership directly from the expired renter to the - new renter. - -6. **Cancel** — the owner reclaims the asset with `cancelrent(asset_id)` whenever no rental - is actively running (also handles expired-but-not-ended rentals by reclaiming holdership - first). A not-yet-activated listing whose owner no longer owns the asset is invalid and - may be cancelled by anyone. + - The total price (`price_per_hour × hours`, oracle-converted if applicable) is deducted from the + renter's balance and paid out like a sale: marketplace fees, the collection fee (with + [royalty splits](Royalty-Splits) and [collection fee](V2-Changes)), remainder to the listing owner. + - AtomicMarket drives AtomicAssets to make the renter the **real owner** of the asset until + `rental_end`, and the asset is locked (its lock state lives in the AtomicAssets `leases` row). + - `expected_price_per_hour` (and `intended_delphi_median` for oracle-priced listings) protect the + renter against listing or price changes between signing and execution. + +3. **Extend** — the *current* renter can call `rentasset` again while their rental is active; the + purchased hours are appended to the current period. The combined period, measured from the lease's + original start, must stay within `maximum_rental_duration`. An extension is a fresh purchase of + the listing's **current** terms (see `editrent`). Rentals never renew automatically. + +4. **Edit** — the owner can change the price, maximum duration and maker marketplace with + `editrent` at **any** time, including while a lease runs: the listing row is the owner's offer + of *future* rentals, separate from the renter's already-purchased lease (which is untouched). + The listing and settlement symbols cannot change — cancel and relist for that. Renters are + protected against repricing races by `expected_price_per_hour`. + +5. **Wrap up** — after `rental_end`, **anyone** may call `endrent(asset_id)`, which triggers the + permissionless AtomicAssets `reclaim`: it returns the asset to the lister and clears the lock, + making the listing rentable again. `endrent` is idempotent — a second call once the asset is + already reclaimed is a no-op — and purely lease-driven, so it works even for delisted leases. + If nobody calls it, the next `rentasset` reclaims the expired lease and re-leases to the new + renter in the same transaction. Until someone reclaims, the renter keeps the asset's utility + past `rental_end` for free — listers (or the platform's keeper cron) should call `endrent` + promptly. + +6. **Cancel** — the owner removes the listing with `cancelrent(asset_id)` at any time. Delisting is + **not** termination: during an active rental it only withdraws the offer of future rentals; the + lease runs to its end and is then reclaimed as usual. Because nothing is escrowed, cancelling is + just removing the listing row; an expired-but-unreclaimed rental is reclaimed to the owner as + part of the cancel. A listing whose owner no longer owns the asset (while unleased) is invalid + and may be cancelled by anyone. ## Actions | Action | Auth | Effect | |---|---|---| -| `announcerent(lister, asset_id, price_per_hour, settlement_symbol, maximum_rental_duration, maker_marketplace)` | lister | Create a rental listing | -| `cancelrent(asset_id)` | owner (anyone if invalid) | Cancel the listing; return the asset if custodied | +| `announcerent(lister, asset_id, price_per_hour, settlement_symbol, maximum_rental_duration, maker_marketplace)` | lister | Create a rental listing (no escrow) | +| `editrent(asset_id, new_price_per_hour, new_maximum_rental_duration, new_maker_marketplace)` | owner | Change the offered terms, even mid-lease; symbols immutable | +| `cancelrent(asset_id)` | owner (anyone if invalid) | Delist (lease unaffected); reclaim first if expired-but-unreclaimed | | `rentasset(renter, asset_id, rental_hours, expected_price_per_hour, intended_delphi_median, taker_marketplace)` | renter | Rent or extend; pays from the renter's balance | -| `endrent(asset_id)` | anyone | Reset an expired rental back to its listed state | +| `endrent(asset_id)` | anyone | After expiry, reclaim the asset to the lister (idempotent, lease-driven) | | `payrentram(payer, asset_id)` | payer | Take over the RAM cost of the listing row | -Log actions: `lognewrent` (listing created), `logrentstart` (custody received, listing -active), `logrental` (rental executed: renter, hours, paid price, rental_end). +Market log actions: `lognewrent` (listing created), `logeditrent` (terms changed), `logrental` +(rental executed: `rental_id`, renter, hours, paid price, `rental_start`, `rental_end`, +`is_extension`). The lock and return events are logged on the AtomicAssets side: `loglock` (lease +opened or extended — distinguishable via `rental_start`) and `logreclaim` (asset returned); both +echo the `rental_id` of the rental that opened the lease, so indexers can join them to the market's +rental without parsing memos. The reclaim path intentionally notifies **no account that could veto +it** (not the renter, not the title owner) so the guaranteed return cannot be aborted — only the +asset's collection is notified. (Corollary: a collection notify contract that throws on `loglock` +can selectively veto lease *creation* for its assets — a de facto per-collection opt-out.) ## The `rentals` table +The row is the owner's offered terms (mutable via `editrent`); the lock/renter/end state lives in +the AtomicAssets `leases` table, and the two join 1:1 on `asset_id`. + | Field | Meaning | |---|---| | `asset_id` | primary key — one listing per asset | | `owner` | the listing creator; receives the rental payouts | -| `holder` | the current renter; empty when not rented out | | `price_per_hour` | in the listing symbol | | `settlement_symbol` | what rentals are actually paid in | -| `maximum_rental_duration` | seconds; cap for a single rental incl. extensions | -| `rental_end` | seconds since epoch; 0 when not rented out (secondary index `rentalends`) | -| `asset_transferred` | true once the asset is in contract custody | -| `maker_marketplace`, `collection_name`, `collection_fee` | listing metadata | +| `maximum_rental_duration` | seconds; cap for a single rental incl. extensions, from the lease start | +| `maker_marketplace` | the marketplace that brokered the listing | +| `collection_name` | the asset's collection | +| `collection_fee` | the collection fee snapshot at listing time (the live fee is re-read at payout) | ## Integration notes for games and dApps -- To honor rentals, resolve an asset's *effective user* through the AtomicAssets `holders` - table: if a row exists for the asset, the `holder` is the active user; otherwise the - owner is. Rented assets have `owner = atomicmarket` and `holder = `. -- `rental_end` is tracked in the market's `rentals` table (with a secondary index by end - time for expiry sweeps). After expiry the holdership remains with the previous renter - until `endrent`, the next rental, or `cancelrent` resets it — treat - `rental_end <= now` as "rental over" regardless of the holders table. +- To honor rentals, treat the AtomicAssets **owner as the effective user**: during a lease the renter + *is* the owner, so no special resolution is needed for utility. The lock state (whether a returnable + lease is in force, and when it ends) is the AtomicAssets `leases` row for the asset. +- The `leases` row carries `title_owner` (the lister the asset returns to), `renter`, `rental_start`, + `rental_end` and the opening `rental_id`. Treat `rental_end <= now` as "rental over"; after expiry + the renter remains the owner until `endrent`, the next `rentasset`, or `cancelrent` triggers the + reclaim. - Royalties apply to rentals exactly as to sales, including the royalty log actions. diff --git a/include/atomicassets-interface.hpp b/include/atomicassets-interface.hpp index 097b554..b457c87 100644 --- a/include/atomicassets-interface.hpp +++ b/include/atomicassets-interface.hpp @@ -148,17 +148,26 @@ namespace atomicassets { typedef multi_index assets_t; - struct holders_s { + // Non-custodial rental lock/title record. A row's existence means the asset + // is actively leased and locked: the renter is the real owner and title_owner + // holds the reclaim right until rental_end. + struct leases_s { uint64_t asset_id; - name holder; - name owner; + name title_owner; + name renter; + name collection_name; + uint32_t rental_start; + uint32_t rental_end; + uint64_t rental_id; - uint64_t primary_key() const { return asset_id; }; - uint64_t by_holder() const { return holder.value; }; + uint64_t primary_key() const { return asset_id; }; + uint64_t by_title_owner() const { return title_owner.value; }; + uint64_t by_rental_end() const { return (uint64_t) rental_end; }; }; - typedef multi_index >> - holders_t; + typedef multi_index >, + indexed_by>> + leases_t; struct offers_s { @@ -228,6 +237,6 @@ namespace atomicassets { template_mutables_t get_template_mutables(name collection_name) {return template_mutables_t(ATOMICASSETS_ACCOUNT, collection_name.value);} assets_t get_assets(name owner) {return assets_t(ATOMICASSETS_ACCOUNT, owner.value);} - holders_t get_holders() {return holders_t(ATOMICASSETS_ACCOUNT, ATOMICASSETS_ACCOUNT.value);} + leases_t get_leases() {return leases_t(ATOMICASSETS_ACCOUNT, ATOMICASSETS_ACCOUNT.value);} }; \ No newline at end of file diff --git a/include/atomicmarket.hpp b/include/atomicmarket.hpp index cc4ae55..3a9bf78 100644 --- a/include/atomicmarket.hpp +++ b/include/atomicmarket.hpp @@ -317,19 +317,22 @@ CONTRACT atomicmarket : public contract { /* Rentals - Custodial rental flow: - 1. The owner announces a rental listing (announcerent), specifying the price per hour, - the settlement symbol, and the maximum duration a single rental can cover - 2. The owner transfers the asset to the atomicmarket contract with the memo "rental", - which activates the listing (the contract becomes the custodial owner) - 3. A renter pays for a number of hours from their deposited balance (rentasset). The + Non-custodial rental flow (renter-as-owner): + 1. The owner announces a rental listing (announcerent). The asset is NOT escrowed - it + stays fully owned and usable by the lister until it is actually rented. + 2. A renter pays for a number of hours from their deposited balance (rentasset). The payment is distributed like a sale payout (market fees, collection fee / royalty - splits, remainder to the listing owner) and the atomicassets HOLDERSHIP of the - asset is moved to the renter, while ownership stays with the contract - 4. After the rental period is over, anyone can reset the holdership back to the - contract (endrent), making the listing rentable again - 5. The owner can cancel the listing and reclaim the asset whenever no rental is - actively running (cancelrent) + splits, remainder to the listing owner) and atomicmarket drives AtomicAssets to make + the RENTER the real owner of the asset (leasestart), parking the lister's reclaim + right in the AtomicAssets leases table. The asset is locked while leased. + 3. An extension by the same renter only bumps the lease end (leaseextend); no second + ownership flip. + 4. After the rental period is over, the asset returns to the lister via the permissionless + AtomicAssets `reclaim` (a keeper, endrent, or cancelrent triggers it). The listing is + rentable again as soon as the lease row is gone; atomicmarket keeps no lock state of its + own (the AtomicAssets leases table is the single source of truth). + 5. The owner can cancel the listing whenever no rental is actively running (cancelrent); + an expired-but-unreclaimed lease is reclaimed as part of the cancel. */ ACTION announcerent( @@ -341,6 +344,13 @@ CONTRACT atomicmarket : public contract { name maker_marketplace ); + ACTION editrent( + uint64_t asset_id, + asset new_price_per_hour, + uint32_t new_maximum_rental_duration, + name new_maker_marketplace + ); + ACTION cancelrent( uint64_t asset_id ); @@ -468,22 +478,28 @@ CONTRACT atomicmarket : public contract { double collection_fee ); - ACTION logrentstart( - uint64_t asset_id, - name lister - ); - ACTION logrental( - uint64_t rental_counter_id, + uint64_t rental_id, uint64_t asset_id, name lister, name renter, uint32_t rental_hours, asset paid_settlement_price, + uint32_t rental_start, uint32_t rental_end, + bool is_extension, name taker_marketplace ); + ACTION logeditrent( + uint64_t asset_id, + name owner, + asset new_price_per_hour, + symbol settlement_symbol, + uint32_t new_maximum_rental_duration, + name new_maker_marketplace + ); + /* Royalty distribution logs - emitted by every settlement (sale, auction, buyoffer, rental) that distributes a collection fee through a royalty split config. One action @@ -679,26 +695,22 @@ CONTRACT atomicmarket : public contract { typedef multi_index template_buyoffers_t; + // Immutable rental listing config. Lock state (renter, start/end, whether it's currently rented) + // is NOT mirrored here - the AtomicAssets leases table is the single source of truth for that. TABLE rentals_s { uint64_t asset_id; name owner; // the listing creator; receives the rental payouts - name holder; // the current renter; name("") when not rented out asset price_per_hour; // denoted in the listing symbol symbol settlement_symbol; // what the rental is actually paid in uint32_t maximum_rental_duration; // seconds; the longest period a rental can cover - uint32_t rental_end; // seconds since epoch; 0 when not rented out - bool asset_transferred; // true once the asset is in contract custody name maker_marketplace; name collection_name; double collection_fee; uint64_t primary_key() const { return asset_id; }; - uint64_t by_rental_end() const { return (uint64_t) rental_end; }; }; - typedef multi_index >> - rentals_t; + typedef multi_index rentals_t; TABLE marketplaces_s { name marketplace_name; @@ -865,6 +877,9 @@ CONTRACT atomicmarket : public contract { void internal_transfer_assets(name to, const vector &asset_ids, const string &memo); + // Triggers the AtomicAssets permissionless reclaim (shared by rentasset, endrent and cancelrent). + void send_aa_reclaim(uint64_t asset_id); + }; diff --git a/src/atomicmarket.cpp b/src/atomicmarket.cpp index 2538c52..b40dfa2 100644 --- a/src/atomicmarket.cpp +++ b/src/atomicmarket.cpp @@ -1796,8 +1796,8 @@ ACTION atomicmarket::fulfilltbuyo( /** * Create a rental listing for a single asset -* For the listing to become active, the lister needs to use the atomicassets transfer action to -* transfer the asset to the atomicmarket contract with the memo "rental" +* Non-custodial: the asset is NOT transferred to the contract. It stays owned and usable by the +* lister until someone actually rents it (rentasset), at which point ownership moves to the renter. * * price_per_hour is denoted in the listing symbol; if it differs from the settlement symbol, a * delphi symbol pair has to be configured and the rental is paid in the settlement symbol at the @@ -1850,12 +1850,9 @@ ACTION atomicmarket::announcerent( rentals.emplace(lister, [&](auto &_rental) { _rental.asset_id = asset_id; _rental.owner = lister; - _rental.holder = name(""); _rental.price_per_hour = price_per_hour; _rental.settlement_symbol = settlement_symbol; _rental.maximum_rental_duration = maximum_rental_duration; - _rental.rental_end = 0; - _rental.asset_transferred = false; _rental.maker_marketplace = maker_marketplace; _rental.collection_name = assets_collection_name; _rental.collection_fee = collection_fee; @@ -1880,13 +1877,76 @@ ACTION atomicmarket::announcerent( /** -* Cancels a rental listing +* Edits the terms of an existing rental listing +* +* Allowed at any time, including while a lease is running: the listing row is the owner's OFFER +* of future rentals, separate from the renter's purchased lease. Without this, the terms would be +* frozen for the whole lease and the expiry second could be sniped at a stale price for a fresh +* full window. An extension by the incumbent renter is a fresh purchase of the current offer, so +* it pays the NEW terms; renters are protected by the expected_price_per_hour pin in rentasset. +* +* The listing symbol and settlement_symbol cannot change (expected_price_per_hour pins amount + +* listing symbol only, so a mutable settlement symbol could charge a renter holding multiple +* deposited tokens in an unintended one). Relist to change symbols. * -* If the listing is not active yet, it can be cancelled by the owner - or by anyone if the -* owner does not own the asset anymore (which makes the listing invalid) +* @required_auth The listing's owner +*/ +ACTION atomicmarket::editrent( + uint64_t asset_id, + asset new_price_per_hour, + uint32_t new_maximum_rental_duration, + name new_maker_marketplace +) { + auto rentals = get_rentals(); + auto rental_itr = rentals.require_find(asset_id, + "No rental listing with this asset_id exists"); + + require_auth(rental_itr->owner); + + check(new_price_per_hour.is_valid(), "Invalid type new_price_per_hour"); + check(new_price_per_hour.amount > 0, "The price per hour must be greater than zero"); + check(new_price_per_hour.symbol == rental_itr->price_per_hour.symbol, + "The listing symbol cannot be changed. Cancel and announce a new listing instead"); + + check(new_maximum_rental_duration >= 3600, + "The maximum rental duration must be at least one hour (3600 seconds)"); + check(new_maximum_rental_duration <= 2419200, + "The maximum rental duration can't be longer than 28 days"); + + check(is_valid_marketplace(new_maker_marketplace), "The maker marketplace is not a valid marketplace"); + + rentals.modify(rental_itr, same_payer, [&](auto &_rental) { + _rental.price_per_hour = new_price_per_hour; + _rental.maximum_rental_duration = new_maximum_rental_duration; + _rental.maker_marketplace = new_maker_marketplace; + }); + + action( + permission_level{get_self(), name("active")}, + get_self(), + name("logeditrent"), + make_tuple( + asset_id, + rental_itr->owner, + new_price_per_hour, + rental_itr->settlement_symbol, + new_maximum_rental_duration, + new_maker_marketplace + ) + ).send(); +} + + +/** +* Cancels a rental listing * -* If the listing is active (the asset is in contract custody), it can only be cancelled by the -* owner and only while no rental is actively running. The asset is then transferred back +* Non-custodially the asset is never escrowed, so cancelling is just removing the listing row. +* Delisting is NOT termination: cancelling during an active rental only withdraws the offer of +* future rentals - the renter's purchased lease is untouched and expires normally (the reclaim +* then returns the asset to the owner; the lease-driven endrent works without a listing row). +* If no rental is active, the owner can cancel - or anyone can, if the owner no longer owns the +* asset (which makes the listing invalid). An expired-but-unreclaimed rental is reclaimed +* (returning the asset to the owner) as part of the cancel. * * @required_auth The listing's owner (see above for the invalid listing exception) */ @@ -1897,47 +1957,32 @@ ACTION atomicmarket::cancelrent( auto rental_itr = rentals.require_find(asset_id, "No rental listing with this asset_id exists"); - if (!rental_itr->asset_transferred) { + uint32_t current_time = current_time_point().sec_since_epoch(); + + atomicassets::leases_t aa_leases = atomicassets::get_leases(); + auto lease_itr = aa_leases.find(asset_id); + + if (lease_itr != aa_leases.end()) { + // A lease row means only the owner may cancel. Active: withdraw the offer only - the + // renter keeps the lease they paid for. Expired but never reclaimed: also return the + // asset to the owner via the permissionless reclaim. + require_auth(rental_itr->owner); + if (lease_itr->rental_end <= current_time) { + send_aa_reclaim(asset_id); + } + } else { + // Not rented: the asset is still with the lister, nothing to return. Anyone may cancel an + // invalid listing (the owner no longer owns the asset); otherwise the owner must authorize. + // Precondition: this non-custodial flow deploys with no legacy custodial rentals in flight + // (the asset is never escrowed in the contract), so "owner no longer owns it" here always + // means the lister moved/sold it, never that the contract holds an escrowed asset. atomicassets::assets_t owner_assets = atomicassets::get_assets(rental_itr->owner); bool is_rental_invalid = owner_assets.find(asset_id) == owner_assets.end(); check(is_rental_invalid || has_auth(rental_itr->owner), "The rental listing is not invalid, therefore the authorization of the owner is needed to cancel it"); - - rentals.erase(rental_itr); - return; - } - - require_auth(rental_itr->owner); - - check(rental_itr->holder == name("") || - rental_itr->rental_end <= current_time_point().sec_since_epoch(), - "The asset is currently rented out. The listing can only be cancelled after the rental period is over"); - - if (rental_itr->holder != name("")) { - // The rental period is over but the holdership was never reset via endrent. - // It needs to be reclaimed BEFORE the asset is transferred back, otherwise the - // holders table row would survive the transfer - action( - permission_level{get_self(), name("active")}, - atomicassets::ATOMICASSETS_ACCOUNT, - name("move"), - make_tuple( - get_self(), - rental_itr->holder, - get_self(), - vector {asset_id}, - string("AtomicMarket Rental Ended") - ) - ).send(); } - internal_transfer_assets( - rental_itr->owner, - vector {asset_id}, - "AtomicMarket Cancelled Rental Listing - Asset ID # " + to_string(asset_id) - ); - rentals.erase(rental_itr); } @@ -1949,8 +1994,8 @@ ACTION atomicmarket::cancelrent( * uses a delphi pairing) is deducted from the renter's balance and paid out like a sale payout * (market fees, collection fee / royalty splits, remainder to the listing owner) * -* The atomicassets HOLDERSHIP of the asset is moved to the renter until the rental period is -* over, while the ownership stays with the atomicmarket contract +* atomicmarket drives AtomicAssets to make the renter the real OWNER of the asset until the +* rental period is over; the lister's reclaim right is parked in the AtomicAssets leases table * * If the renter already holds an active rental for this asset, the new hours extend the * current rental period instead (the combined remaining period must stay within the listing's @@ -1974,9 +2019,6 @@ ACTION atomicmarket::rentasset( auto rental_itr = rentals.require_find(asset_id, "No rental listing with this asset_id exists"); - check(rental_itr->asset_transferred, - "This rental listing is not active yet. The owner first has to transfer the asset to the atomicmarket account"); - check(renter != rental_itr->owner, "You can't rent your own asset"); check(rental_itr->price_per_hour == expected_price_per_hour, @@ -1986,16 +2028,25 @@ ACTION atomicmarket::rentasset( uint32_t current_time = current_time_point().sec_since_epoch(); - bool has_active_rental = rental_itr->holder != name("") && rental_itr->rental_end > current_time; - bool is_extension = has_active_rental && rental_itr->holder == renter; + // The AtomicAssets leases table is the source of truth for the on-chain lock state; the + // rentals mirror below can lag a keeper-driven reclaim. + atomicassets::leases_t aa_leases = atomicassets::get_leases(); + auto lease_itr = aa_leases.find(asset_id); + bool lease_exists = lease_itr != aa_leases.end(); + bool active_lease = lease_exists && lease_itr->rental_end > current_time; + bool is_extension = active_lease && lease_itr->renter == renter; - check(!has_active_rental || is_extension, + check(!active_lease || is_extension, "This asset is currently rented out. It can be rented again once the current rental period is over"); uint64_t added_duration = (uint64_t) rental_hours * 3600; - uint64_t new_rental_end = (uint64_t)(is_extension ? rental_itr->rental_end : current_time) + added_duration; + uint64_t base_time = is_extension ? (uint64_t) lease_itr->rental_end : (uint64_t) current_time; + uint64_t new_rental_end = base_time + added_duration; - check(new_rental_end - current_time <= rental_itr->maximum_rental_duration, + // Cap the total period from the lease's original open (rental_start), not from "now" - measuring + // from now would let a renter roll the window forward forever and never return the asset. + uint64_t lease_start = is_extension ? (uint64_t) lease_itr->rental_start : (uint64_t) current_time; + check(new_rental_end - lease_start <= rental_itr->maximum_rental_duration, "The rental period would exceed the maximum rental duration of this listing"); check(is_valid_marketplace(taker_marketplace), "The taker marketplace is not a valid marketplace"); @@ -2014,7 +2065,13 @@ ACTION atomicmarket::rentasset( internal_decrease_balance(renter, settlement_price); - uint64_t rental_counter_id = consume_counter(name("rental")); + uint64_t rental_id = consume_counter(name("rental")); + + // asset_scope must be the asset's current owner at payout time: the renter for an extension or + // expired re-rental, else the listing owner. This holds only because the inline reclaim/leasestart + // below run after this body. Don't reorder them before the payout - distribute_collection_fee uses + // find() (not require_find), so a wrong scope SILENTLY drops template/attribute royalties. + name asset_scope = lease_exists ? lease_itr->renter : rental_itr->owner; internal_payout_sale( settlement_price, @@ -2023,51 +2080,59 @@ ACTION atomicmarket::rentasset( taker_marketplace, rental_itr->collection_name, vector {asset_id}, - get_self(), // the asset is in contract custody + asset_scope, name("rental"), - rental_counter_id, - "AtomicMarket Rental Payout - ID #" + to_string(rental_counter_id) + rental_id, + "AtomicMarket Rental Payout - ID #" + to_string(rental_id) ); - if (!is_extension) { - // Move the holdership to the renter. If a previous rental expired without endrent - // being called, the holdership still sits with the previous renter and is moved - // directly from them; otherwise it is moved from the contract itself - name move_from = rental_itr->holder == name("") ? get_self() : rental_itr->holder; - - if (move_from != renter) { - action( - permission_level{get_self(), name("active")}, - atomicassets::ATOMICASSETS_ACCOUNT, - name("move"), - make_tuple( - get_self(), - move_from, - renter, - vector {asset_id}, - string("AtomicMarket Rental - ID # ") + to_string(rental_counter_id) - ) - ).send(); + if (is_extension) { + // Same renter, already the owner: only extend the lease end in AtomicAssets. The lease + // keeps the rental_id that opened it; this extension is identified by its own logrental. + action( + permission_level{get_self(), name("active")}, + atomicassets::ATOMICASSETS_ACCOUNT, + name("leaseextend"), + make_tuple(asset_id, (uint32_t) new_rental_end) + ).send(); + } else { + // A previous lease that expired but was never reclaimed still locks the asset. Clear it + // first (permissionless reclaim) so leasestart can re-lease from the listing owner. + if (lease_exists) { + send_aa_reclaim(asset_id); } + // Make the renter the real AtomicAssets owner and lock the asset (the lister's reclaim + // right is parked in the AtomicAssets leases table). rental_id is echoed in the AA lease + // logs so indexers can join loglock/logreclaim to this rental structurally. + action( + permission_level{get_self(), name("active")}, + atomicassets::ATOMICASSETS_ACCOUNT, + name("leasestart"), + make_tuple( + rental_itr->owner, + renter, + asset_id, + (uint32_t) new_rental_end, + rental_id, + string("AtomicMarket Rental - ID # ") + to_string(rental_id) + ) + ).send(); } - rentals.modify(rental_itr, same_payer, [&](auto &_rental) { - _rental.holder = renter; - _rental.rental_end = (uint32_t) new_rental_end; - }); - action( permission_level{get_self(), name("active")}, get_self(), name("logrental"), make_tuple( - rental_counter_id, + rental_id, asset_id, rental_itr->owner, renter, rental_hours, settlement_price, + (uint32_t) lease_start, (uint32_t) new_rental_end, + is_extension, taker_marketplace ) ).send(); @@ -2075,42 +2140,32 @@ ACTION atomicmarket::rentasset( /** -* Wraps up an expired rental by moving the holdership of the asset back to the atomicmarket -* contract, making the listing rentable again +* Wraps up an expired rental, returning the asset to the lister +* +* The asset is returned by the permissionless AtomicAssets `reclaim`. endrent is just the +* AtomicMarket-facing trigger for it once the rental is over; a keeper calling AA `reclaim` +* directly does the same thing. Can be called by anyone. * -* This can be called by anyone - it only resets an expired rental back to its listed state +* Purely lease-driven: it does not require a rentals listing row, so it keeps working for +* leases whose listing was delisted (cancelrent) while they ran. * * @required_auth None */ ACTION atomicmarket::endrent( uint64_t asset_id ) { - auto rentals = get_rentals(); - auto rental_itr = rentals.require_find(asset_id, - "No rental listing with this asset_id exists"); - - check(rental_itr->holder != name(""), "This asset is not currently rented out"); + // AtomicAssets leases is the source of truth. No row means it was already reclaimed (by a keeper + // or otherwise), so endrent is a no-op - it is idempotent and safe to call again. + atomicassets::leases_t aa_leases = atomicassets::get_leases(); + auto lease_itr = aa_leases.find(asset_id); + if (lease_itr == aa_leases.end()) { + return; + } - check(rental_itr->rental_end <= current_time_point().sec_since_epoch(), + check(lease_itr->rental_end <= current_time_point().sec_since_epoch(), "The rental period is not over yet"); - action( - permission_level{get_self(), name("active")}, - atomicassets::ATOMICASSETS_ACCOUNT, - name("move"), - make_tuple( - get_self(), - rental_itr->holder, - get_self(), - vector {asset_id}, - string("AtomicMarket Rental Ended") - ) - ).send(); - - rentals.modify(rental_itr, same_payer, [&](auto &_rental) { - _rental.holder = name(""); - _rental.rental_end = 0; - }); + send_aa_reclaim(asset_id); } @@ -2280,36 +2335,9 @@ void atomicmarket::receive_asset_transfer( ) ).send(); - } else if (memo == "rental") { - auto rentals = get_rentals(); - - for (uint64_t asset_id : asset_ids) { - auto rental_itr = rentals.require_find(asset_id, - ("No rental listing exists for one of the transferred assets - " + to_string(asset_id)).c_str()); - - check(rental_itr->owner == from, - ("A rental listing for this asset exists, but it belongs to another account - " - + to_string(asset_id)).c_str()); - - check(!rental_itr->asset_transferred, - ("The asset for this rental listing has already been transferred - " + to_string(asset_id)).c_str()); - - rentals.modify(rental_itr, same_payer, [&](auto &_rental) { - _rental.asset_transferred = true; - }); - - action( - permission_level{get_self(), name("active")}, - get_self(), - name("logrentstart"), - make_tuple( - asset_id, - from - ) - ).send(); - } - } else { + // Non-custodial rentals never transfer the asset to the contract, so a "rental" memo is + // no longer a valid intake and falls through to this rejection. check(false, "Invalid memo"); } } @@ -2465,23 +2493,16 @@ ACTION atomicmarket::lognewrent( require_recipient(lister); } -ACTION atomicmarket::logrentstart( - uint64_t asset_id, - name lister -) { - require_auth(get_self()); - - require_recipient(lister); -} - ACTION atomicmarket::logrental( - uint64_t rental_counter_id, + uint64_t rental_id, uint64_t asset_id, name lister, name renter, uint32_t rental_hours, asset paid_settlement_price, + uint32_t rental_start, uint32_t rental_end, + bool is_extension, name taker_marketplace ) { require_auth(get_self()); @@ -2490,6 +2511,20 @@ ACTION atomicmarket::logrental( require_recipient(renter); } + +ACTION atomicmarket::logeditrent( + uint64_t asset_id, + name owner, + asset new_price_per_hour, + symbol settlement_symbol, + uint32_t new_maximum_rental_duration, + name new_maker_marketplace +) { + require_auth(get_self()); + + require_recipient(owner); +} + // The royalty distribution logs intentionally notify nobody (see the header comment): // a require_recipient to an arbitrary payout recipient would let a recipient contract // assert in its notification handler and block the collection's settlements @@ -2542,6 +2577,7 @@ name atomicmarket::get_collection_and_check_assets( atomicassets::assets_t owner_assets = atomicassets::get_assets(owner); + atomicassets::leases_t aa_leases = atomicassets::get_leases(); name assets_collection_name = name(""); // All assets have to belong to the same collection, so the templates table only needs to @@ -2553,6 +2589,13 @@ name atomicmarket::get_collection_and_check_assets( ("The specified account does not own at least one of the assets - " + to_string(asset_id)).c_str()); + // A rental-locked (leased) asset cannot be listed by any caller of this helper: + // sale, auction, buyoffer, or a new rental listing. Refuse it early here rather than + // letting it fail at settlement time. The AtomicAssets transfer/offer guards remain + // the authoritative backstop. + check(aa_leases.find(asset_id) == aa_leases.end(), + ("One of the assets is currently rented out (locked) - " + to_string(asset_id)).c_str()); + if (assets_collection_name == name("")) { assets_collection_name = asset_itr->collection_name; } else { @@ -3499,3 +3542,13 @@ void atomicmarket::internal_transfer_assets( ) ).send(); } + + +void atomicmarket::send_aa_reclaim(uint64_t asset_id) { + action( + permission_level{get_self(), name("active")}, + atomicassets::ATOMICASSETS_ACCOUNT, + name("reclaim"), + make_tuple(asset_id) + ).send(); +} diff --git a/tests/fixtures/atomicassets/atomicassets.abi b/tests/fixtures/atomicassets/atomicassets.abi index 4d94992..72e4847 100644 --- a/tests/fixtures/atomicassets/atomicassets.abi +++ b/tests/fixtures/atomicassets/atomicassets.abi @@ -601,7 +601,26 @@ ] }, { - "name": "holders_s", + "name": "init", + "base": "", + "fields": [] + }, + { + "name": "leaseextend", + "base": "", + "fields": [ + { + "name": "asset_id", + "type": "uint64" + }, + { + "name": "rental_end", + "type": "uint32" + } + ] + }, + { + "name": "leases_s", "base": "", "fields": [ { @@ -609,19 +628,60 @@ "type": "uint64" }, { - "name": "holder", + "name": "title_owner", "type": "name" }, { - "name": "owner", + "name": "renter", + "type": "name" + }, + { + "name": "collection_name", "type": "name" + }, + { + "name": "rental_start", + "type": "uint32" + }, + { + "name": "rental_end", + "type": "uint32" + }, + { + "name": "rental_id", + "type": "uint64" } ] }, { - "name": "init", + "name": "leasestart", "base": "", - "fields": [] + "fields": [ + { + "name": "title_owner", + "type": "name" + }, + { + "name": "renter", + "type": "name" + }, + { + "name": "asset_id", + "type": "uint64" + }, + { + "name": "rental_end", + "type": "uint32" + }, + { + "name": "rental_id", + "type": "uint64" + }, + { + "name": "memo", + "type": "string" + } + ] }, { "name": "locktemplate", @@ -701,6 +761,40 @@ } ] }, + { + "name": "loglock", + "base": "", + "fields": [ + { + "name": "collection_name", + "type": "name" + }, + { + "name": "asset_id", + "type": "uint64" + }, + { + "name": "title_owner", + "type": "name" + }, + { + "name": "renter", + "type": "name" + }, + { + "name": "rental_start", + "type": "uint32" + }, + { + "name": "rental_end", + "type": "uint32" + }, + { + "name": "rental_id", + "type": "uint64" + } + ] + }, { "name": "logmint", "base": "", @@ -747,36 +841,6 @@ } ] }, - { - "name": "logmove", - "base": "", - "fields": [ - { - "name": "collection_name", - "type": "name" - }, - { - "name": "owner", - "type": "name" - }, - { - "name": "from", - "type": "name" - }, - { - "name": "to", - "type": "name" - }, - { - "name": "asset_ids", - "type": "uint64[]" - }, - { - "name": "memo", - "type": "string" - } - ] - }, { "name": "lognewoffer", "base": "", @@ -867,6 +931,32 @@ } ] }, + { + "name": "logreclaim", + "base": "", + "fields": [ + { + "name": "collection_name", + "type": "name" + }, + { + "name": "asset_id", + "type": "uint64" + }, + { + "name": "title_owner", + "type": "name" + }, + { + "name": "renter", + "type": "name" + }, + { + "name": "rental_id", + "type": "uint64" + } + ] + }, { "name": "logsetdata", "base": "", @@ -979,32 +1069,6 @@ } ] }, - { - "name": "move", - "base": "", - "fields": [ - { - "name": "owner", - "type": "name" - }, - { - "name": "from", - "type": "name" - }, - { - "name": "to", - "type": "name" - }, - { - "name": "asset_ids", - "type": "uint64[]" - }, - { - "name": "memo", - "type": "string" - } - ] - }, { "name": "offers_s", "base": "", @@ -1067,6 +1131,16 @@ } ] }, + { + "name": "reclaim", + "base": "", + "fields": [ + { + "name": "asset_id", + "type": "uint64" + } + ] + }, { "name": "redtemplmax", "base": "", @@ -1127,6 +1201,20 @@ } ] }, + { + "name": "rentalcfg_s", + "base": "", + "fields": [ + { + "name": "rental_market", + "type": "name" + }, + { + "name": "max_lease_seconds", + "type": "uint32" + } + ] + }, { "name": "schema_types_s", "base": "", @@ -1205,6 +1293,16 @@ } ] }, + { + "name": "setleasecap", + "base": "", + "fields": [ + { + "name": "max_lease_seconds", + "type": "uint32" + } + ] + }, { "name": "setmarketfee", "base": "", @@ -1233,6 +1331,16 @@ } ] }, + { + "name": "setrentmkt", + "base": "", + "fields": [ + { + "name": "rental_market", + "type": "name" + } + ] + }, { "name": "setschematyp", "base": "", @@ -1496,6 +1604,16 @@ "type": "init", "ricardian_contract": "" }, + { + "name": "leaseextend", + "type": "leaseextend", + "ricardian_contract": "" + }, + { + "name": "leasestart", + "type": "leasestart", + "ricardian_contract": "" + }, { "name": "locktemplate", "type": "locktemplate", @@ -1512,13 +1630,13 @@ "ricardian_contract": "" }, { - "name": "logmint", - "type": "logmint", + "name": "loglock", + "type": "loglock", "ricardian_contract": "" }, { - "name": "logmove", - "type": "logmove", + "name": "logmint", + "type": "logmint", "ricardian_contract": "" }, { @@ -1536,6 +1654,11 @@ "type": "logrampayer", "ricardian_contract": "" }, + { + "name": "logreclaim", + "type": "logreclaim", + "ricardian_contract": "" + }, { "name": "logsetdata", "type": "logsetdata", @@ -1557,13 +1680,13 @@ "ricardian_contract": "" }, { - "name": "move", - "type": "move", + "name": "payofferram", + "type": "payofferram", "ricardian_contract": "" }, { - "name": "payofferram", - "type": "payofferram", + "name": "reclaim", + "type": "reclaim", "ricardian_contract": "" }, { @@ -1601,6 +1724,11 @@ "type": "setlastpayer", "ricardian_contract": "" }, + { + "name": "setleasecap", + "type": "setleasecap", + "ricardian_contract": "" + }, { "name": "setmarketfee", "type": "setmarketfee", @@ -1611,6 +1739,11 @@ "type": "setrampayer", "ricardian_contract": "" }, + { + "name": "setrentmkt", + "type": "setrentmkt", + "ricardian_contract": "" + }, { "name": "setschematyp", "type": "setschematyp", @@ -1674,8 +1807,8 @@ "key_types": [] }, { - "name": "holders", - "type": "holders_s", + "name": "leases", + "type": "leases_s", "index_type": "i64", "key_names": [], "key_types": [] @@ -1687,6 +1820,13 @@ "key_names": [], "key_types": [] }, + { + "name": "rentalcfg", + "type": "rentalcfg_s", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, { "name": "schemas", "type": "schemas_s", diff --git a/tests/fixtures/atomicassets/atomicassets.wasm b/tests/fixtures/atomicassets/atomicassets.wasm index f3579af..8fd8f45 100755 Binary files a/tests/fixtures/atomicassets/atomicassets.wasm and b/tests/fixtures/atomicassets/atomicassets.wasm differ diff --git a/tests/market-smoke.test.js b/tests/market-smoke.test.js index 39bab48..eef55af 100644 --- a/tests/market-smoke.test.js +++ b/tests/market-smoke.test.js @@ -11,8 +11,8 @@ const fs = require('fs'); * receive_token_transfer, and atomicassets::lognewoffer routes to receive_asset_offer. * 2. Sale payouts: legacy collection fee (no royalty config) and the full royalty split * engine (founders / template / attribute categories, exact integer math incl. dust). - * 3. Custodial rentals: announce -> custody transfer -> rent (holdership moves to the renter - * in the atomicassets holders table) -> extension -> expiry -> endrent -> cancelrent. + * 3. Non-custodial rentals: announce (no escrow) -> rent (renter becomes the real atomicassets + * owner via a leases lock) -> extension -> expiry -> reclaim/endrent -> cancelrent. */ const WAX = (amount) => `${amount.toFixed(8)} WAX`; @@ -30,7 +30,7 @@ describe('atomicmarket end to end', () => { const aaTables = { assets: (scope) => atomicassets.tables.assets(nameToBigInt(Name.from(scope))).getTableRows(), - holders: () => atomicassets.tables.holders(nameToBigInt(atomicassets.name)).getTableRows(), + leases: () => atomicassets.tables.leases(nameToBigInt(atomicassets.name)).getTableRows(), offers: () => atomicassets.tables.offers(nameToBigInt(atomicassets.name)).getTableRows(), }; const marketTables = { @@ -74,6 +74,10 @@ describe('atomicmarket end to end', () => { await atomicassets.actions.init([]).send(`${AA}@active`); await atomicmarket.actions.init([]).send(`${MARKET}@active`); + // Leasing is opt-in in AtomicAssets (rentalcfg defaults to disabled); authorize + // this market so the non-custodial rental flow can drive leasestart/leaseextend. + await atomicassets.actions.setrentmkt([MARKET]).send(`${AA}@active`); + await atomicmarket.actions.addconftoken(['eosio.token', '8,WAX']).send(`${MARKET}@active`); await mintTokens(token, 'WAX', 8, 1000000000, 10000, [buyer, renter, renter2]); @@ -373,9 +377,6 @@ describe('atomicmarket end to end', () => { await atomicmarket.actions.announcerent([ 'seller', ASSET1, '0.50 USD', '8,WAX', 86400, '', ]).send('seller@active'); - await atomicassets.actions.transfer([ - 'seller', MARKET, [ASSET1], 'rental', - ]).send('seller@active'); await deposit('renter', 20); @@ -388,7 +389,42 @@ describe('atomicmarket end to end', () => { expect(balanceOf('fees.atomic')).toEqual([WAX(0.4)]); // 2% of 20 WAX const sellerTokens = token.tables.accounts(nameToBigInt(Name.from('seller'))).getTableRows(); expect(sellerTokens).toEqual([{ balance: WAX(17.6) }]); // 88% of 20 WAX - expect(marketTables.rentals()[0].holder).toBe('renter'); + expect(aaTables.leases()[0].renter).toBe('renter'); + // non-custodial: the renter is the real owner now + expect(aaTables.assets('renter').map((a) => a.asset_id)).toContain(ASSET1); + }); + + test('delphi-priced rental extension settles the added hours at the oracle rate', async () => { + await setupDelphiPair(); + + await atomicmarket.actions.announcerent([ + 'seller', ASSET1, '0.50 USD', '8,WAX', 86400, '', + ]).send('seller@active'); + + await deposit('renter', 40); + + // fresh rental: 2h x 0.50 USD = 1.00 USD = 20 WAX (leasestart path) + await atomicmarket.actions.rentasset([ + 'renter', ASSET1, 2, '0.50 USD', 500, '', + ]).send('renter@active'); + const endAfterRent = Number(aaTables.leases()[0].rental_end); + + // same renter extends by 2h while active: exercises the leaseextend inline path under + // settlement conversion (another 1.00 USD = 20 WAX), no second ownership flip + await atomicmarket.actions.rentasset([ + 'renter', ASSET1, 2, '0.50 USD', 500, '', + ]).send('renter@active'); + + // lease end advanced by the added hours; renter is still the real owner (no re-lease) + expect(Number(aaTables.leases()[0].rental_end)).toBe(endAfterRent + 2 * 3600); + expect(aaTables.leases()[0].renter).toBe('renter'); + expect(aaTables.assets('renter').map((a) => a.asset_id)).toContain(ASSET1); + + // both payouts settled in WAX at the oracle rate (2 x 20 WAX total) + expect(balanceOf('author')).toEqual([WAX(4)]); // 10% of 40 WAX + expect(balanceOf('fees.atomic')).toEqual([WAX(0.8)]); // 2% of 40 WAX + const sellerTokens = token.tables.accounts(nameToBigInt(Name.from('seller'))).getTableRows(); + expect(sellerTokens).toEqual([{ balance: WAX(35.2) }]); // 88% of 40 WAX }); test('lowering the collection fee after listing discounts the executed sale', async () => { @@ -688,26 +724,24 @@ describe('atomicmarket end to end', () => { /* 3. Rentals */ /* ------------------------------------------------------------------ */ + // Non-custodial: announcing a rental is all it takes - the asset stays with the lister. const listAndActivateRental = async () => { await atomicmarket.actions.announcerent([ 'seller', ASSET1, WAX(0.5), '8,WAX', 86400, '', ]).send('seller@active'); - await atomicassets.actions.transfer([ - 'seller', MARKET, [ASSET1], 'rental', - ]).send('seller@active'); }; - test('full rental lifecycle: announce, custody, rent, extend, expire, endrent, cancel', async () => { + test('full rental lifecycle: announce, rent, extend, expire, endrent, cancel', async () => { await listAndActivateRental(); - let rentals = marketTables.rentals(); + const rentals = marketTables.rentals(); expect(rentals.length).toBe(1); - expect(rentals[0].asset_transferred).toBe(true); expect(rentals[0].owner).toBe('seller'); - expect(rentals[0].holder).toBe(''); - // the market contract is now the custodial owner - expect(aaTables.assets(MARKET).length).toBe(1); + // non-custodial: the asset is still the seller's until it's rented, and nothing is leased + expect(aaTables.assets('seller').map((a) => a.asset_id)).toContain(ASSET1); + expect(aaTables.assets(MARKET).length).toBe(0); + expect(aaTables.leases()).toEqual([]); // rent for 2 hours = 1.0 WAX await deposit('renter', 2); @@ -715,13 +749,20 @@ describe('atomicmarket end to end', () => { 'renter', ASSET1, 2, WAX(0.5), 0, '', ]).send('renter@active'); - rentals = marketTables.rentals(); - expect(rentals[0].holder).toBe('renter'); - - // holdership moved to the renter inside atomicassets - expect(aaTables.holders()).toEqual([ - { asset_id: ASSET1, holder: 'renter', owner: MARKET }, - ]); + // the renter is now the REAL atomicassets owner, the asset is locked, and the lease row (the + // single source of truth for lock state) holds the lister's reclaim right + expect(aaTables.assets('renter').map((a) => a.asset_id)).toContain(ASSET1); + expect(aaTables.assets('seller').map((a) => a.asset_id)).not.toContain(ASSET1); + const leased = aaTables.leases(); + expect(leased).toEqual([{ + asset_id: ASSET1, + title_owner: 'seller', + renter: 'renter', + collection_name: COL, + rental_start: Number(leased[0].rental_end) - 2 * 3600, + rental_end: Number(leased[0].rental_end), + rental_id: 1, // the AM rental counter, echoed on the lease for indexer joins + }]); // payout: 1.0 WAX -> 2% fees.atomic, 10% author, 88% straight to the seller expect(balanceOf('author')).toEqual([WAX(0.1)]); @@ -737,39 +778,41 @@ describe('atomicmarket end to end', () => { ]).send('renter2@active') ).rejects.toThrow(/currently rented out/); - // owner can't cancel while active + // cancelling during an active lease is the owner's delist right, nobody else's + // (the delist-during-lease semantics have their own dedicated test below) await expect( - atomicmarket.actions.cancelrent([ASSET1]).send('seller@active') - ).rejects.toThrow(/currently rented out/); + atomicmarket.actions.cancelrent([ASSET1]).send('renter2@active') + ).rejects.toThrow(/missing required authority/); // endrent before expiry fails await expect( atomicmarket.actions.endrent([ASSET1]).send('renter2@active') ).rejects.toThrow(/not over yet/); - // the same renter can extend (1 more hour = 0.5 WAX) - const endBefore = Number(marketTables.rentals()[0].rental_end); + // the same renter can extend (1 more hour = 0.5 WAX); no second ownership flip + const endBefore = Number(aaTables.leases()[0].rental_end); await atomicmarket.actions.rentasset([ 'renter', ASSET1, 1, WAX(0.5), 0, '', ]).send('renter@active'); - expect(Number(marketTables.rentals()[0].rental_end)).toBe(endBefore + 3600); + expect(Number(aaTables.leases()[0].rental_end)).toBe(endBefore + 3600); + expect(aaTables.assets('renter').map((a) => a.asset_id)).toContain(ASSET1); - // jump past the rental end; anyone can wrap it up + // jump past the rental end; anyone can wrap it up (endrent triggers the reclaim) blockchain.addTime(TimePoint.fromMilliseconds(4 * 3600 * 1000)); await atomicmarket.actions.endrent([ASSET1]).send('renter2@active'); - expect(aaTables.holders()).toEqual([]); - rentals = marketTables.rentals(); - expect(rentals[0].holder).toBe(''); - expect(Number(rentals[0].rental_end)).toBe(0); + // asset returned to the lister; lock cleared; the listing stays (rentable again) + expect(aaTables.leases()).toEqual([]); + expect(aaTables.assets('seller').map((a) => a.asset_id)).toContain(ASSET1); + expect(marketTables.rentals().length).toBe(1); - // owner cancels; the asset comes back + // owner cancels the (now idle) listing await atomicmarket.actions.cancelrent([ASSET1]).send('seller@active'); expect(marketTables.rentals()).toEqual([]); expect(aaTables.assets('seller').length).toBe(2); // ASSET1 + ASSET2 }); - test('renting after expiry without endrent moves holdership from the old renter', async () => { + test('endrent is idempotent: a second call after the reclaim is a no-op', async () => { await listAndActivateRental(); await deposit('renter', 1); @@ -777,21 +820,152 @@ describe('atomicmarket end to end', () => { 'renter', ASSET1, 1, WAX(0.5), 0, '', ]).send('renter@active'); + // expire, then wrap up once: the first endrent reclaims (lease cleared, asset back to lister) blockchain.addTime(TimePoint.fromMilliseconds(2 * 3600 * 1000)); + await atomicmarket.actions.endrent([ASSET1]).send('renter2@active'); + expect(aaTables.leases()).toEqual([]); + expect(marketTables.rentals().length).toBe(1); + + // a second endrent finds no lease to reclaim - it must no-op, not throw (a keeper reclaim + // followed by an endrent, or two endrent calls racing, must not brick). + await expect( + atomicmarket.actions.endrent([ASSET1]).send('renter2@active') + ).resolves.not.toThrow(); + + // state unchanged: still no lease, listing still present, asset still with the lister + expect(aaTables.leases()).toEqual([]); + expect(marketTables.rentals().length).toBe(1); + expect(aaTables.assets('seller').map((a) => a.asset_id)).toContain(ASSET1); + }); - // no endrent in between - renter2 rents directly + test('renting after expiry without endrent re-leases from the old renter to the new one', async () => { + await listAndActivateRental(); + + await deposit('renter', 1); + await atomicmarket.actions.rentasset([ + 'renter', ASSET1, 1, WAX(0.5), 0, '', + ]).send('renter@active'); + + blockchain.addTime(TimePoint.fromMilliseconds(2 * 3600 * 1000)); + + // no endrent in between - renter2 rents directly. rentasset reclaims the expired lease + // (asset back to the lister) and then re-leases to renter2 in the same transaction. await deposit('renter2', 1); await atomicmarket.actions.rentasset([ 'renter2', ASSET1, 1, WAX(0.5), 0, '', ]).send('renter2@active'); - expect(aaTables.holders()).toEqual([ - { asset_id: ASSET1, holder: 'renter2', owner: MARKET }, - ]); - expect(marketTables.rentals()[0].holder).toBe('renter2'); + expect(aaTables.assets('renter2').map((a) => a.asset_id)).toContain(ASSET1); + expect(aaTables.assets('renter').map((a) => a.asset_id)).not.toContain(ASSET1); + const leased = aaTables.leases(); + expect(leased).toEqual([{ + asset_id: ASSET1, + title_owner: 'seller', + renter: 'renter2', + collection_name: COL, + rental_start: Number(leased[0].rental_end) - 1 * 3600, + rental_end: Number(leased[0].rental_end), + rental_id: 2, // the re-lease is a NEW rental: fresh counter id on the fresh lease + }]); + }); + + test('editrent reprices the listing mid-lease: extensions and re-rents pay the NEW terms', async () => { + await listAndActivateRental(); // 0.5 WAX/h, 24h max + + await deposit('renter', 10); + await atomicmarket.actions.rentasset([ + 'renter', ASSET1, 2, WAX(0.5), 0, '', + ]).send('renter@active'); + + // mid-lease the owner reprices to 2 WAX/h and shortens the max to 12h: the listing row + // is the owner's OFFER of future rentals, separate from the renter's purchased lease + await atomicmarket.actions.editrent([ + ASSET1, WAX(2), 12 * 3600, '', + ]).send('seller@active'); + expect(marketTables.rentals()[0].price_per_hour).toBe(WAX(2)); + expect(Number(marketTables.rentals()[0].maximum_rental_duration)).toBe(12 * 3600); + + // the running lease is untouched + expect(aaTables.leases()[0].renter).toBe('renter'); + + // an extension is a fresh purchase of the current offer: the old price is now rejected... + await expect( + atomicmarket.actions.rentasset([ + 'renter', ASSET1, 1, WAX(0.5), 0, '', + ]).send('renter@active') + ).rejects.toThrow(/differs from the expected price/); + // ...and the new price charges 2 WAX for 1h + const endBefore = Number(aaTables.leases()[0].rental_end); + await atomicmarket.actions.rentasset([ + 'renter', ASSET1, 1, WAX(2), 0, '', + ]).send('renter@active'); + expect(Number(aaTables.leases()[0].rental_end)).toBe(endBefore + 3600); + + // stale-price snipe regression: at expiry, a re-rent must pay the CURRENT terms, not + // the price the listing had when the previous lease started + blockchain.addTime(TimePoint.fromMilliseconds(4 * 3600 * 1000)); + await deposit('renter2', 10); + await expect( + atomicmarket.actions.rentasset([ + 'renter2', ASSET1, 1, WAX(0.5), 0, '', + ]).send('renter2@active') + ).rejects.toThrow(/differs from the expected price/); + await atomicmarket.actions.rentasset([ + 'renter2', ASSET1, 1, WAX(2), 0, '', + ]).send('renter2@active'); + expect(aaTables.leases()[0].renter).toBe('renter2'); + }); + + test('editrent validation: owner-only, same listing symbol, duration bounds', async () => { + await listAndActivateRental(); + + await expect( + atomicmarket.actions.editrent([ + ASSET1, WAX(2), 86400, '', + ]).send('buyer@active') + ).rejects.toThrow(/missing required authority/); + + await expect( + atomicmarket.actions.editrent([ + ASSET1, '2.00 USD', 86400, '', + ]).send('seller@active') + ).rejects.toThrow(/listing symbol cannot be changed/); + + await expect( + atomicmarket.actions.editrent([ + ASSET1, WAX(2), 1800, '', + ]).send('seller@active') + ).rejects.toThrow(/at least one hour/); }); - test('cancelrent on an expired-but-not-ended rental reclaims holdership and the asset', async () => { + test('delisting during an active lease withdraws the offer without touching the lease', async () => { + await listAndActivateRental(); + + await deposit('renter', 1); + await atomicmarket.actions.rentasset([ + 'renter', ASSET1, 2, WAX(0.5), 0, '', + ]).send('renter@active'); + + // only the owner may delist while the lease runs (the listing is not "invalid": + // the owner not owning the asset is the normal rented state) + await expect( + atomicmarket.actions.cancelrent([ASSET1]).send('renter2@active') + ).rejects.toThrow(/missing required authority/); + await atomicmarket.actions.cancelrent([ASSET1]).send('seller@active'); + + // listing gone, lease (and the renter's ownership) untouched + expect(marketTables.rentals()).toEqual([]); + expect(aaTables.leases()[0].renter).toBe('renter'); + expect(aaTables.assets('renter').map((a) => a.asset_id)).toContain(ASSET1); + + // the lease still expires normally, and the lease-driven endrent needs no listing row + blockchain.addTime(TimePoint.fromMilliseconds(3 * 3600 * 1000)); + await atomicmarket.actions.endrent([ASSET1]).send('renter2@active'); + expect(aaTables.leases()).toEqual([]); + expect(aaTables.assets('seller').map((a) => a.asset_id)).toContain(ASSET1); + }); + + test('cancelrent on an expired-but-not-ended rental reclaims the asset to the lister', async () => { await listAndActivateRental(); await deposit('renter', 1); @@ -803,11 +977,61 @@ describe('atomicmarket end to end', () => { await atomicmarket.actions.cancelrent([ASSET1]).send('seller@active'); - expect(aaTables.holders()).toEqual([]); + expect(aaTables.leases()).toEqual([]); expect(marketTables.rentals()).toEqual([]); expect(aaTables.assets('seller').length).toBe(2); }); + test('an invalid rental listing (owner moved the asset) can be cancelled by anyone', async () => { + await listAndActivateRental(); // announce only - never rented, so no lease row + + // the lister moves the asset out of band; the listing is now invalid (owner no longer owns it) + await atomicassets.actions.transfer([ + 'seller', 'buyer', [ASSET1], 'moved after announcing', + ]).send('seller@active'); + + // a third party (not the owner) may cancel the invalid listing + await atomicmarket.actions.cancelrent([ASSET1]).send('renter2@active'); + expect(marketTables.rentals()).toEqual([]); + + // a still-valid listing, by contrast, needs the owner's authorization to cancel + await atomicassets.actions.transfer([ + 'buyer', 'seller', [ASSET1], 'return', + ]).send('buyer@active'); + await listAndActivateRental(); + await expect( + atomicmarket.actions.cancelrent([ASSET1]).send('renter2@active') + ).rejects.toThrow(/authorization of the owner is needed/); + }); + + test('extensions are capped at the maximum duration measured from the lease start', async () => { + // listAndActivateRental lists with maximum_rental_duration = 86400s (24h) + await listAndActivateRental(); + await deposit('renter', 20); + + // rent 20h (start = T0, end = T0 + 20h) + await atomicmarket.actions.rentasset([ + 'renter', ASSET1, 20, WAX(0.5), 0, '', + ]).send('renter@active'); + const end = Number(aaTables.leases()[0].rental_end); + + // advance 10h, still active. Extending by 5h would push the total to 25h + // from the lease start (> 24h) even though only 15h would remain from + // "now" - the from-start cap rejects it (a rolling-window cap would not). + blockchain.addTime(TimePoint.fromMilliseconds(10 * 3600 * 1000)); + await expect( + atomicmarket.actions.rentasset([ + 'renter', ASSET1, 5, WAX(0.5), 0, '', + ]).send('renter@active') + ).rejects.toThrow(/maximum rental duration/); + + // extending by 4h reaches exactly 24h from start, which is allowed + await atomicmarket.actions.rentasset([ + 'renter', ASSET1, 4, WAX(0.5), 0, '', + ]).send('renter@active'); + expect(Number(aaTables.leases()[0].rental_end)).toBe(end + 4 * 3600); + }); + test('rental payouts respect royalty splits', async () => { await setupRoyaltySplits(); await listAndActivateRental(); @@ -825,10 +1049,35 @@ describe('atomicmarket end to end', () => { expect(balanceOf('author')).toEqual(['0.00000002 WAX']); }); + test('rental extension pays template/attribute royalties from the renter scope', async () => { + // Pins the asset_scope invariant for the EXTENSION branch (asset_scope = renter): + // by extension time the renter is the AA owner, so the royalty engine must read the + // asset's template_id/attributes from the renter's scope. If asset_scope were wrong + // (or the lease flip were reordered before payout), distribute_collection_fee would + // silently drop temproy1/attrroy1 and this test's doubled balances would fail. + await setupRoyaltySplits(); + await listAndActivateRental(); + + await deposit('renter', 2); + // fresh rental (asset_scope = owner): 1.0 WAX + await atomicmarket.actions.rentasset([ + 'renter', ASSET1, 2, WAX(0.5), 0, '', + ]).send('renter@active'); + // same-renter extension while still active (asset_scope = renter): another 1.0 WAX + await atomicmarket.actions.rentasset([ + 'renter', ASSET1, 2, WAX(0.5), 0, '', + ]).send('renter@active'); + + // template + attribute royalty recipients must be paid for BOTH payouts (2 x 0.03333333), + // proving the extension read the asset from the renter scope, not an empty one. + expect(balanceOf('temproy1')).toEqual(['0.06666666 WAX']); + expect(balanceOf('attrroy1')).toEqual(['0.06666666 WAX']); + }); + test('rental payouts use the discounted fee at execution time', async () => { await listAndActivateRental(); - // discount applied after the listing was created and the asset custodied + // discount applied after the listing was created await atomicassets.actions.setmarketfee([COL, 0.05]).send('author@active'); await deposit('renter', 1); @@ -897,6 +1146,59 @@ describe('atomicmarket end to end', () => { ).rejects.toThrow(/differs from the expected price/); }); + test('a rented (locked) asset cannot be listed for sale, auction, buyoffer or re-rent', async () => { + await listAndActivateRental(); + await deposit('renter', 2); + await atomicmarket.actions.rentasset([ + 'renter', ASSET1, 2, WAX(0.5), 0, '', + ]).send('renter@active'); + + // the renter is the current owner, but the asset is rental-locked + await expect( + atomicmarket.actions.announcesale([ + 'renter', [ASSET1], WAX(1), '8,WAX', '', + ]).send('renter@active') + ).rejects.toThrow(/rented out|locked/); + + await expect( + atomicmarket.actions.announceauct([ + 'renter', [ASSET1], WAX(1), 600, '', + ]).send('renter@active') + ).rejects.toThrow(/rented out|locked/); + + await deposit('buyer', 1); + await expect( + atomicmarket.actions.createbuyo([ + 'buyer', 'renter', WAX(1), [ASSET1], '', '', + ]).send('buyer@active') + ).rejects.toThrow(/rented out|locked/); + + await expect( + atomicmarket.actions.announcerent([ + 'renter', ASSET1, WAX(0.5), '8,WAX', 86400, '', + ]).send('renter@active') + ).rejects.toThrow(/rented out|locked/); + + // after expiry the permissionless reclaim lifts the lock and the lister can list it again + blockchain.addTime(TimePoint.fromMilliseconds(3 * 3600 * 1000)); + await atomicmarket.actions.endrent([ASSET1]).send('renter@active'); + await expect( + atomicmarket.actions.announcesale([ + 'seller', [ASSET1], WAX(1), '8,WAX', '', + ]).send('seller@active') + ).resolves.not.toThrow(); + }); + + test('a stray "rental" memo transfer to the market is now rejected', async () => { + await listAndActivateRental(); + // non-custodial rentals never escrow the asset, so the old "rental" intake is gone + await expect( + atomicassets.actions.transfer([ + 'seller', MARKET, [ASSET1], 'rental', + ]).send('seller@active') + ).rejects.toThrow(/Invalid memo/); + }); + /* ------------------------------------------------------------------ */ /* 4. Default marketplace creator + balance migration (XPR) */ /* ------------------------------------------------------------------ */