diff --git a/CLAUDE.md b/CLAUDE.md index e8431bd..ac205fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,15 +5,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview AtomicMarket V2.0 is a marketplace smart contract (C++ / Antelope, formerly EOSIO) for trading -and renting [AtomicAssets](https://github.com/pinknetworkx/atomicassets-contract) NFTs. It builds +[AtomicAssets](https://github.com/pinknetworkx/atomicassets-contract) NFTs. It builds on the AtomicAssets V2.0 contract (developed in the sibling repo `../atomicassets-contract`, branch `feat/v2-integration`) and extends the upstream AtomicMarket with: - **Royalty splits**: a collection's market fee can be distributed across weighted founders, per-template recipients, and attribute-matching rules instead of going entirely to the collection author -- **Custodial rentals**: per-hour asset rentals using the AtomicAssets V2 `holders` table and - `move` action (the market contract holds ownership, the renter receives holdership) - **Single-asset listings only**: bundle listings were removed (legacy bundle rows are auto-cancelled when touched); multiple assets are traded via multiple listings in one transaction @@ -35,9 +33,6 @@ branch `feat/v2-integration`) and extends the upstream AtomicMarket with: - **Sales / auctions / buyoffers / template buyoffers**: the upstream listing types; every listing holds exactly ONE asset -- **Rentals**: `announcerent` -> transfer with memo `"rental"` (custody) -> `rentasset` - (pays per-hour price, holdership moves to renter) -> `endrent` after expiry (anyone) -> - `cancelrent` (owner reclaims the asset) - **Royalty configs** (`royaltyconf` / `royaltytemp` / `royaltyattr` tables): mutations require the collection AUTHOR's authorization only (financial config; authorized accounts are deliberately rejected). Category splits renormalize across categories that have payees; @@ -88,8 +83,8 @@ npx jest market # run test files matching a pattern ### Test Structure - `tests/market-smoke.test.js` - end-to-end suite: notification dispatch routing, sale payouts - (legacy + royalty splits with exact integer math), execution-time collection fee, bundle-removal behavior - (including legacy rows injected via `tables.X(...).set(...)`), and the full rental lifecycle + (legacy + royalty splits with exact integer math), execution-time collection fee, and + bundle-removal behavior (including legacy rows injected via `tables.X(...).set(...)`) - `tests/fixtures/eosio.token/` - token contract fixture (wasm + abi) - `tests/fixtures/atomicassets/` - the AtomicAssets V2 contract the market integrates with. Rebuild from the sibling repo (`bash ../atomicassets-contract/build.sh`) and re-copy the diff --git a/README.md b/README.md index 72a805f..5201a50 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # AtomicMarket V2.0 -AtomicMarket is a marketplace to sell, auction and rent out [AtomicAssets](https://github.com/pinknetworkx/atomicassets-contract) NFTs. V2.0 builds on the AtomicAssets V2.0 contract. +AtomicMarket is a marketplace to sell and auction [AtomicAssets](https://github.com/pinknetworkx/atomicassets-contract) NFTs. V2.0 builds on the AtomicAssets V2.0 contract. ### [Documentation can be found here.](docs/wiki/Home.md) @@ -18,10 +18,6 @@ AtomicMarket is a marketplace to sell, auction and rent out [AtomicAssets](https Instead of using transfers, AtomicAssets **offers** are used for sales. These offers are only accepted when someone buys the NFTs for sale. Therefore, sellers keep ownership over their NFTs while they are listed on the AtomicMarket. -- **Custodial rentals** - - Assets can be rented out per hour. The owner lists an asset and transfers it into contract custody; renters pay from their deposited balance and receive the AtomicAssets V2 *holdership* of the asset for the rental period, while ownership stays with the contract. Rental payments are distributed like sale payouts, including royalties. - - **Royalty splits** On top of the collection fee, collection authors can configure how that fee is distributed: globally weighted founder accounts, per-template recipient lists, and attribute-matching rules (e.g. `rarity = legendary`), each with their own weights. The collection fee applied at settlement is always the fee at execution time, so author fee changes — discounts *and* increases — take effect immediately on all existing listings. diff --git a/docs/api-integration.md b/docs/api-integration.md index b5ca283..86f6f0c 100644 --- a/docs/api-integration.md +++ b/docs/api-integration.md @@ -8,59 +8,31 @@ for the AtomicMarket V2.0 contract. - **Status**: merged, CI green, **not yet deployed on-chain** — you can develop against a testnet deployment or the VeRT suite (`yarn install && bash build.sh && npm test`; `tests/market-smoke.test.js` doubles as an executable spec of every flow described here) -- **Depends on**: AtomicAssets V2.0 (`holders` table, `move` action, `templates2` mutable - template data) — the rentals feature is built on AA holdership +- **Depends on**: AtomicAssets V2.0 (`templates2` mutable template data) ## 1. Interface delta at a glance -**New tables** (section 2): `rentals`, `royaltyconf`, `royaltytemp`, `royaltyattr`. +**New tables** (section 2): `royaltyconf`, `royaltytemp`, `royaltyattr`. No existing table changed its layout. **New actions**: | Group | Actions | |---|---| -| Rentals | `announcerent`, `cancelrent`, `rentasset`, `endrent`, `payrentram` | -| Rental logs | `lognewrent`, `logrentstart`, `logrental` | | Royalty config | `setroyalconf`, `delroyalconf`, `settemplroy`, `deltemplroy`, `setattrroy`, `delattrroy` | | Royalty logs | `logroyfound`, `logroytempl`, `logroyattr`, `logroydust` | -**New transfer memo**: AtomicAssets transfers to the market account with memo `rental` -activate rental listings (existing memos unchanged: `deposit` for tokens, `auction` for -auction transfers, `sale` / `buyoffer` / `tbuyoffer` for offers). - **Behavior changes to existing actions** (section 5 — these WILL break naive state machines): single-asset listings, legacy-bundle auto-cancellation, execution-time -collection fee (applied at settlement, section 5.3). +collection fee (applied at settlement, section 4.3). **Important for trace consumption**: none of the royalty log actions notify any account (`require_recipient` deliberately absent — a notified recipient contract could abort settlements). If your reader is notification-driven rather than trace-driven, these -actions are invisible to it. `logrental` notifies the lister and renter; `lognewrent` and -`logrentstart` notify the lister. +actions are invisible to it. ## 2. New tables -### `rentals` — scope: contract account - -One row per listed asset (a rental listing holds exactly one asset; the asset id IS the key). - -| Field | Type | Notes | -|---|---|---| -| `asset_id` | uint64 | primary key | -| `owner` | name | listing creator; receives rental payouts | -| `holder` | name | current renter; empty name when not rented out | -| `price_per_hour` | asset | in the listing symbol | -| `settlement_symbol` | symbol | what rentals are paid in (delphi pair if ≠ listing symbol) | -| `maximum_rental_duration` | uint32 | seconds; cap per rental incl. extensions | -| `rental_end` | uint32 | sec since epoch; 0 when not rented out | -| `asset_transferred` | bool | true once the asset is in contract custody | -| `maker_marketplace` | name | | -| `collection_name` | name | | -| `collection_fee` | float64 | fee at listing time; informational only — settlement uses the execution-time fee (section 5.3) | - -Secondary index `rentalends` (uint64 on `rental_end`) — useful for expiry sweeps. - ### `royaltyconf` — scope: contract account Per-collection royalty split config. PK: `collection`. @@ -89,49 +61,9 @@ PK: `template_id` (stored int32, keyed as uint64). Fields: `template_id` (int32) | `recipients` | `ROYALTYPAIR[]` | | | `lookup_hash` | checksum256 | sha256(pack(source, field, value)); secondary index `byhash` | -## 3. Rentals — workflow and indexing signals - -State machine of a `rentals` row: - -``` -(none) --announcerent--> LISTED(asset_transferred=false) -LISTED --AA transfer memo "rental"--> ACTIVE(asset_transferred=true, holder="") -ACTIVE --rentasset--> RENTED(holder=renter, rental_end=T) -RENTED --rentasset by same renter, before T--> RENTED(rental_end += hours*3600) [extension] -RENTED, after T --rentasset by anyone--> RENTED(new holder, rental_end = now + hours*3600) -RENTED, after T --endrent (anyone)--> ACTIVE(holder="", rental_end=0) -ACTIVE or expired-RENTED --cancelrent (owner)--> (row erased, asset returned) -LISTED --cancelrent (owner; anyone if owner no longer owns the asset)--> (row erased) -``` - -Per-action effects: - -| Action / signal | Auth | State effects | Log emitted | -|---|---|---|---| -| `announcerent(lister, asset_id, price_per_hour, settlement_symbol, maximum_rental_duration, maker_marketplace)` | lister | rentals row created | `lognewrent(asset_id, lister, price_per_hour, settlement_symbol, maximum_rental_duration, maker_marketplace, collection_name, collection_fee)` | -| AA `transfer(from, to=market, [asset_id], "rental")` | — | `asset_transferred = true`; market becomes AA owner | `logrentstart(asset_id, lister)` per asset (a multi-asset transfer activates each asset's own listing) | -| `rentasset(renter, asset_id, rental_hours, expected_price_per_hour, intended_delphi_median, taker_marketplace)` | renter | renter balance debited; payout distributed (section 4); `holder = renter`, `rental_end` set; inline AA `move` shifts holdership to the renter — skipped when the renter already holds it (extensions, AND an expired rental re-rented by the same renter without an intervening `endrent`). Do not assume every non-extension `logrental` has a sibling AA `move` trace | `logrental(rental_counter_id, asset_id, lister, renter, rental_hours, paid_settlement_price, rental_end, taker_marketplace)` | -| `endrent(asset_id)` | **anyone** | `holder = ""`, `rental_end = 0`; inline AA `move` returns holdership to the market | — | -| `cancelrent(asset_id)` | owner (anyone if listing invalid & not activated) | row erased; if custodied: holdership reclaimed if needed, asset transferred back to owner | — | -| `payrentram(payer, asset_id)` | payer | row erased + re-created with new RAM payer; **contents unchanged** (do not treat as a state change) | — | +## 3. Royalty splits — config and settlement -Indexing recipes: - -- **`rental_counter_id`** in `logrental` is a global, monotonically increasing rental event - id (from the `counters` table, name `rental`) — a natural primary key for rental events. -- **Extensions**: trust `logrental.rental_end` directly; it is the new absolute end. An - extension is recognizable as `renter == previous holder && previous rental_end > block_time`. -- **Effective user of an asset**: from the AA `holders` table (`owner = market account, - holder = renter`). After expiry the holders row persists until `endrent` / next rental / - `cancelrent` — treat `rental_end <= now` as "rental over" regardless. -- **Paid price**: `logrental.paid_settlement_price` is the final settled amount (already - delphi-converted if the listing is oracle-priced). -- `rentasset` aborts with "currently rented out" for non-holders during an active rental — - no state to index on failure (failed transactions don't reach the chain). - -## 4. Royalty splits — config and settlement - -### 4.1 Config CRUD +### 3.1 Config CRUD All six actions require the **collection author's** authorization (authorized accounts are rejected — config controls fund routing). The author pays RAM. @@ -149,17 +81,17 @@ Validation guarantees you can rely on: recipient lists are 1–64 entries, weigh duplicate recipients, all recipients exist; rule values are never float/double or vectors; `source` is 0 in merged mode, 1–4 in granular mode. -### 4.2 Settlement: who gets the collection fee +### 3.2 Settlement: who gets the collection fee -Every settlement — `purchasesale`, `auctclaimsel`, `acceptbuyo`, `fulfilltbuyo`, -`rentasset` — distributes the payment: maker fee, taker fee, **collection fee**, bonus +Every settlement — `purchasesale`, `auctclaimsel`, `acceptbuyo`, `fulfilltbuyo` — +distributes the payment: maker fee, taker fee, **collection fee**, bonus fees, remainder to the seller/lister (transferred out directly; everything else accrues to the `balances` table, claimed via `withdraw`). For the collection fee: - **No `royaltyconf` row** → the full collection fee goes to the author's balance. - **No log is emitted in this case** — compute the author's earnings yourself (see 5.3 for + **No log is emitted in this case** — compute the author's earnings yourself (see 4.3 for the amount). - **Legacy bundle payout** (only reachable via `auctclaimsel` on a buyer-claimed pre-V2 bundle auction) → the full collection fee goes to the author, even when a royalty config @@ -181,15 +113,15 @@ You do NOT need to re-implement the split math (category renormalization, two-le weighting, dust) — the logs carry the final per-recipient amounts. The math lives in `distribute_collection_fee` in `src/atomicmarket.cpp` if you want to cross-check. -## 5. Breaking behavior changes +## 4. Breaking behavior changes -### 5.1 Single-asset listings +### 4.1 Single-asset listings `announcesale`, `announceauct`, `createbuyo` now reject `asset_ids.size() != 1`. All new -sales/auctions/buyoffers reference exactly one asset. (`rentals` and `tbuyoffers` were -single-asset by design.) +sales/auctions/buyoffers reference exactly one asset. (`tbuyoffers` were single-asset by +design.) -### 5.2 Legacy bundle rows auto-cancel — execution actions no longer always mean a trade +### 4.2 Legacy bundle rows auto-cancel — execution actions no longer always mean a trade Rows with `asset_ids.length > 1` can only predate V2. When one is touched, the action **succeeds** but performs a cancellation instead of a trade. Your state machine must branch @@ -206,14 +138,14 @@ on the row's asset count (which you already have in your DB): | `cancelsale` / `cancelauct` | now allowed for **anyone** on bundles (and bundle auctions with bids, refunding the bidder) — EXCEPT partially-claimed bundle auctions, which can't be cancelled | cancelled | | offer memo `sale` / transfer memo `auction` with >1 assets | transaction aborts (bundles can't activate) | nothing | -### 5.3 Execution-time collection fee +### 4.3 Execution-time collection fee The applied collection fee is the collection's `market_fee` on AtomicAssets **at execution time**, read live at settlement — *not* the fee stored in the listing row. The -`collection_fee` field in sales/auctions/buyoffers/rentals rows is therefore informational +`collection_fee` field in sales/auctions/buyoffers rows is therefore informational only (the fee at listing time); it does not determine the payout. -- With a royalty config: the applied amount = the logged `logroy*` sum (section 4.2). +- With a royalty config: the applied amount = the logged `logroy*` sum (section 3.2). - Without: applied amount = `floor(current AA market_fee × price)` — you already track AA `setmarketfee`, so the current fee is in your DB. @@ -221,37 +153,31 @@ This is deliberate product behavior: the collection author has full control, and — down *or* up — apply to all existing listings immediately. Expect fee changes mid-listing to be common, not exceptional. -### 5.4 Royalty config authorization +### 4.4 Royalty config authorization Unlike most collection-scoped things in the ecosystem, royalty config actions are valid ONLY with the **author's** auth. If you surface "who may edit", do not show authorized accounts for these. -## 6. Unchanged +## 5. Unchanged Deposits/withdrawals and the `balances` table, marketplace registration and maker/taker fees, bonus fees, the sale/auction/buyoffer/tbuyoffer happy paths and their existing log actions (`lognewsale`, `lognewauct`, `lognewbuyo`, `lognewtbuyo`, `logsalestart`, -`logauctstart`), assert actions, `paysaleram`/`payauctram`/`paybuyoram` (joined by -`payrentram`), counters, the config singleton layout (version reports `2.0.0`). +`logauctstart`), assert actions, `paysaleram`/`payauctram`/`paybuyoram`, counters, the +config singleton layout (version reports `2.0.0`). The deployed ABI keeps the legacy spellings your readers already handle (`key`/`value` pair fields, `uint8[]`) — `make release` post-processes the raw CDT 4.1 ABI exactly like the AtomicAssets V2 release does. -## 7. Quick reference: everything to add to your action filter +## 6. Quick reference: everything to add to your action filter ``` setroyalconf delroyalconf settemplroy deltemplroy setattrroy delattrroy -announcerent cancelrent rentasset endrent payrentram -lognewrent logrentstart logrental logroyfound logroytempl logroyattr logroydust ``` -Plus: AA `transfer` notifications to the market account with memo `rental`, and the AA -`holders` table / `logmove` if you want real-time holdership (you likely index those for -AA V2 already). - Questions: the VeRT suite (`tests/market-smoke.test.js`) demonstrates every flow above end-to-end, including the exact balance outcomes — it is the fastest way to answer "what exactly happens on chain when X". diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index ffd4ca0..65f7f6b 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -1,6 +1,6 @@ # AtomicMarket V2.0 -AtomicMarket is a marketplace smart contract for selling, auctioning and renting out +AtomicMarket is a marketplace smart contract for selling and auctioning [AtomicAssets](https://github.com/pinknetworkx/atomicassets-contract) NFTs on Antelope chains. V2.0 builds on the AtomicAssets V2.0 contract. @@ -9,14 +9,13 @@ V2.0 builds on the AtomicAssets V2.0 contract. | Feature | Summary | Details | |---|---|---| | **Royalty splits** | The collection fee can be distributed across weighted founders, per-template recipients, and attribute-matching rules instead of going entirely to the collection author | [Royalty Splits](Royalty-Splits) | -| **Custodial rentals** | Assets can be rented out per hour; renters receive the AtomicAssets *holdership* while ownership stays in contract custody | [Rentals](Rentals) | | **Single-asset listings** | Every sale, auction and buyoffer contains exactly one asset; bundle listings were removed | [V2 Changes](V2-Changes) | | **Execution-time collection fee** | Settlements apply the collection's fee at execution time, so author fee changes — down *or* up — take effect immediately on all existing listings | [V2 Changes](V2-Changes) | | **CPU optimizations** | Lazy table construction, per-action config caching, size-capped raw reads of the collections table | [V2 Changes](V2-Changes) | ## Core concepts (unchanged from V1) -- **Deposits**: buyers and renters pay from a deposited balance. Transfer any supported token +- **Deposits**: buyers pay from a deposited balance. Transfer any supported token to the market account with the memo `deposit`; withdraw any time with the `withdraw` action. - **Sales**: `announcesale`, then activate by creating an AtomicAssets trade offer to the market account with the memo `sale`. Purchased with `purchasesale`. @@ -27,12 +26,12 @@ V2.0 builds on the AtomicAssets V2.0 contract. marketplaces of a settlement each receive a configurable share (1% by default). - **Delphi pricing**: listings can be priced in one symbol (e.g. USD) and settled in another (e.g. WAX) at the [delphioracle](https://github.com/eostitan/delphioracle) exchange rate at - execution time. This works for sales **and rentals** in V2. + execution time. ## Settlement payout order -Every settlement (sale purchase, auction seller claim, buyoffer acceptance/fulfillment, -rental) distributes the payment as follows: +Every settlement (sale purchase, auction seller claim, buyoffer acceptance/fulfillment) +distributes the payment as follows: 1. Maker marketplace fee (default 1%) 2. Taker marketplace fee (default 1%) diff --git a/docs/wiki/README.md b/docs/wiki/README.md index 0a5b394..54706ae 100644 --- a/docs/wiki/README.md +++ b/docs/wiki/README.md @@ -13,5 +13,5 @@ cd /tmp/market-wiki git add -A && git commit -m "Sync wiki from docs/wiki" && git push ``` -Page links between the files use wiki-style page names (e.g. `[Rentals](Rentals)`), which +Page links between the files use wiki-style page names (e.g. `[Royalty Splits](Royalty-Splits)`), which resolve on the GitHub wiki. When viewing these files inside the repo, append `.md` mentally. diff --git a/docs/wiki/Rentals.md b/docs/wiki/Rentals.md deleted file mode 100644 index f30b247..0000000 --- a/docs/wiki/Rentals.md +++ /dev/null @@ -1,111 +0,0 @@ -# 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. - -## Lifecycle - -``` -announcerent ──> transfer (memo "rental") ──> rentasset ──> [extensions] ──> endrent ──┐ - │ (custody) (rented) (listed again)│ - └──────────────────────────── cancelrent (asset returned) <────────────────────────┘ -``` - -1. **List** — the owner announces the listing with its terms: - - ```sh - cleos push action atomicmarket announcerent '{ - "lister": "alice", - "asset_id": 1099511627776, - "price_per_hour": "0.50000000 WAX", - "settlement_symbol": "8,WAX", - "maximum_rental_duration": 604800, - "maker_marketplace": "" - }' -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). - -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: - - ```sh - cleos push action atomicmarket rentasset '{ - "renter": "bob", - "asset_id": 1099511627776, - "rental_hours": 48, - "expected_price_per_hour": "0.50000000 WAX", - "intended_delphi_median": 0, - "taker_marketplace": "" - }' -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. - -## 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 | -| `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 | -| `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). - -## The `rentals` table - -| 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 | - -## 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. -- Royalties apply to rentals exactly as to sales, including the royalty log actions. diff --git a/docs/wiki/Royalty-Splits.md b/docs/wiki/Royalty-Splits.md index a072183..daa7708 100644 --- a/docs/wiki/Royalty-Splits.md +++ b/docs/wiki/Royalty-Splits.md @@ -5,7 +5,7 @@ With a **royalty split config**, the author can instead distribute it across thr categories: 1. **Founders** — a global recipient list applying to every settlement of the collection -2. **Templates** — recipient lists keyed by the sold/rented asset's `template_id` +2. **Templates** — recipient lists keyed by the sold asset's `template_id` 3. **Attributes** — rules that match an attribute `(field, value)` on the asset Everything in this system is configured by — and only by — the **collection author**. diff --git a/docs/wiki/V2-Changes.md b/docs/wiki/V2-Changes.md index 8f2b057..1ffd09a 100644 --- a/docs/wiki/V2-Changes.md +++ b/docs/wiki/V2-Changes.md @@ -1,7 +1,7 @@ # V2 Changes & Migration This page covers the V2.0 changes that affect existing users, integrators and indexers, -beyond the two new feature sets ([Royalty Splits](Royalty-Splits), [Rentals](Rentals)). +beyond the new feature set ([Royalty Splits](Royalty-Splits)). ## Single-asset listings (bundle removal) @@ -31,7 +31,7 @@ regardless of the fee stored when the listing was created. Changing a collection AtomicAssets therefore takes effect immediately on **all existing listings** of the collection — both reductions (temporary, collection-wide promotion windows) *and* increases apply, giving the collection author full control over their fee. This applies to sales, -auctions, both buyoffer types and rentals. The listing row still records the fee at listing +auctions and both buyoffer types. The listing row still records the fee at listing time, but only for informational / indexing purposes — it no longer affects the payout. **Note for sellers:** because the fee is read at settlement, a collection author can raise @@ -43,8 +43,7 @@ end may want to surface the live fee at the moment of sale. ## For indexers and API providers -New tables: `royaltyconf`, `royaltytemp`, `royaltyattr` (see [Royalty Splits](Royalty-Splits)), -`rentals` (see [Rentals](Rentals)). +New tables: `royaltyconf`, `royaltytemp`, `royaltyattr` (see [Royalty Splits](Royalty-Splits)). New actions to index: @@ -54,8 +53,6 @@ New actions to index: the logs of one settlement sum to exactly the collection fee, so royalty earnings can be indexed without re-implementing the split math. These actions notify no accounts; read them from action traces. -- Rentals: `announcerent`, `cancelrent`, `rentasset`, `endrent`, `payrentram`, plus the - logs `lognewrent`, `logrentstart`, `logrental` - Note that rule ids in `logroyattr` are never reused (persistent counter), so they are stable keys for historical data. diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 139cbd8..6ce4bb5 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -2,5 +2,4 @@ - [Home](Home) - [Royalty Splits](Royalty-Splits) -- [Rentals](Rentals) - [V2 Changes & Migration](V2-Changes) diff --git a/include/atomicassets-interface.hpp b/include/atomicassets-interface.hpp index 097b554..b592385 100644 --- a/include/atomicassets-interface.hpp +++ b/include/atomicassets-interface.hpp @@ -148,19 +148,6 @@ namespace atomicassets { typedef multi_index assets_t; - struct holders_s { - uint64_t asset_id; - name holder; - name owner; - - uint64_t primary_key() const { return asset_id; }; - uint64_t by_holder() const { return holder.value; }; - }; - typedef multi_index >> - holders_t; - - struct offers_s { uint64_t offer_id; name sender; @@ -228,6 +215,5 @@ 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);} }; \ No newline at end of file diff --git a/include/atomicmarket.hpp b/include/atomicmarket.hpp index cc4ae55..34f643e 100644 --- a/include/atomicmarket.hpp +++ b/include/atomicmarket.hpp @@ -314,51 +314,6 @@ 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 - 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) - */ - - ACTION announcerent( - name lister, - uint64_t asset_id, - asset price_per_hour, - symbol settlement_symbol, - uint32_t maximum_rental_duration, - name maker_marketplace - ); - - ACTION cancelrent( - uint64_t asset_id - ); - - ACTION rentasset( - name renter, - uint64_t asset_id, - uint32_t rental_hours, - asset expected_price_per_hour, - uint64_t intended_delphi_median, - name taker_marketplace - ); - - ACTION endrent( - uint64_t asset_id - ); - - ACTION paysaleram( name payer, uint64_t sale_id @@ -374,11 +329,6 @@ CONTRACT atomicmarket : public contract { uint64_t buyoffer_id ); - ACTION payrentram( - name payer, - uint64_t asset_id - ); - [[eosio::on_notify("atomicassets::transfer")]] void receive_asset_transfer( name from, @@ -457,36 +407,9 @@ CONTRACT atomicmarket : public contract { uint64_t auction_id ); - ACTION lognewrent( - uint64_t asset_id, - name lister, - asset price_per_hour, - symbol settlement_symbol, - uint32_t maximum_rental_duration, - name maker_marketplace, - name collection_name, - double collection_fee - ); - - ACTION logrentstart( - uint64_t asset_id, - name lister - ); - - ACTION logrental( - uint64_t rental_counter_id, - uint64_t asset_id, - name lister, - name renter, - uint32_t rental_hours, - asset paid_settlement_price, - uint32_t rental_end, - name taker_marketplace - ); - /* - Royalty distribution logs - emitted by every settlement (sale, auction, buyoffer, - rental) that distributes a collection fee through a royalty split config. One action + Royalty distribution logs - emitted by every settlement (sale, auction, buyoffer) + that distributes a collection fee through a royalty split config. One action per asset and category (one per matched rule for the attributes category), carrying the exact amounts credited to the internal balances table. @@ -679,27 +602,6 @@ CONTRACT atomicmarket : public contract { typedef multi_index template_buyoffers_t; - 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; - TABLE marketplaces_s { name marketplace_name; name creator; @@ -770,7 +672,6 @@ CONTRACT atomicmarket : public contract { auctions_t get_auctions() { return auctions_t(get_self(), get_self().value); } buyoffers_t get_buyoffers() { return buyoffers_t(get_self(), get_self().value); } template_buyoffers_t get_template_buyoffers() { return template_buyoffers_t(get_self(), get_self().value); } - rentals_t get_rentals() { return rentals_t(get_self(), get_self().value); } marketplaces_t get_marketplaces() { return marketplaces_t(get_self(), get_self().value); } counters_t get_counters() { return counters_t(get_self(), get_self().value); } diff --git a/resource/atomicmarket.contracts.md b/resource/atomicmarket.contracts.md index 5336d7a..8bcf7dc 100644 --- a/resource/atomicmarket.contracts.md +++ b/resource/atomicmarket.contracts.md @@ -754,28 +754,6 @@ This action may only be called with the permission of {{payer}}. -

payrentram

- ---- -spec_version: "0.2.0" -title: Pay for the RAM of a rental listing -summary: '{{nowrap payer}} pays for the RAM of the rental listing for the asset {{nowrap asset_id}}' -icon: https://atomicassets.io/image/logo256.png#108AEE3530F4EB368A4B0C28800894CFBABF46534F48345BF6453090554C52D5 ---- - -Description: -
-{{payer}} pays for the RAM associated with the table entry of the rental listing for the asset with the ID {{asset_id}}. The content of the table entry does not change. -
- -Clauses: -
-This action may only be called with the permission of {{payer}}. -
- - - -

addbonusfee

--- @@ -877,7 +855,7 @@ icon: https://atomicassets.io/image/logo256.png#108AEE3530F4EB368A4B0C28800894CF
The royalty split config of the collection {{collection_name}} is created or updated. -When a sale, auction, buyoffer or rental of an asset belonging to {{collection_name}} settles, the collection fee share of the payment is divided between three categories, weighted {{split_founders}} (founders) : {{split_templates}} (templates) : {{split_attributes}} (attributes). Categories that have no payees for the settled asset are renormalized away, so the weights only need to be relative to each other. +When a sale, auction or buyoffer of an asset belonging to {{collection_name}} settles, the collection fee share of the payment is divided between three categories, weighted {{split_founders}} (founders) : {{split_templates}} (templates) : {{split_attributes}} (attributes). Categories that have no payees for the settled asset are renormalized away, so the weights only need to be relative to each other. The founders category is distributed to the configured founders list, proportional to the configured weights. @@ -1110,124 +1088,6 @@ This action may only be called with the permission of {{seller}}. -

announcerent

- ---- -spec_version: "0.2.0" -title: Announce a rental listing -summary: '{{nowrap lister}} announces a rental listing for the asset {{nowrap asset_id}}' -icon: https://atomicassets.io/image/logo256.png#108AEE3530F4EB368A4B0C28800894CFBABF46534F48345BF6453090554C52D5 ---- - -Description: -
-{{lister}} announces a rental listing for the asset with the ID {{asset_id}}. - -For this listing to become active, {{lister}} has to transfer the asset to the AtomicMarket account with the memo "rental". The AtomicMarket account then holds the asset in custody for the lifetime of the listing. - -The asset can be rented for {{price_per_hour}} per hour, settled in {{symbol_to_symbol_code settlement_symbol}}. A single rental (including extensions by the same renter) can cover at most {{maximum_rental_duration}} seconds. - -While the asset is rented out, the renter receives the AtomicAssets HOLDERSHIP of the asset; the ownership stays with the AtomicMarket account. - -{{#if maker_marketplace}}The marketplace with the name {{maker_marketplace}} facilitates this listing. -{{else}}The default marketplace facilitates this listing. -{{/if}} - -When the asset is rented, the marketplaces facilitating the listing and the rental, and the collection's royalty recipients (according to the collection's royalty split configuration, or the collection author if none exists) each receive a share of the rental payment; the remainder is paid out to {{lister}}. The collection fee applied is the collection's fee at the time of a rental, regardless of whether it is higher or lower than at the time of this announcement. -
- -Clauses: -
-This action may only be called with the permission of {{lister}}. -
- - - - -

cancelrent

- ---- -spec_version: "0.2.0" -title: Cancel a rental listing -summary: 'The rental listing for the asset {{nowrap asset_id}} is cancelled' -icon: https://atomicassets.io/image/logo256.png#108AEE3530F4EB368A4B0C28800894CFBABF46534F48345BF6453090554C52D5 ---- - -Description: -
-The rental listing for the asset with the ID {{asset_id}} is cancelled. - -A listing can only be cancelled while no rental is actively running. If the asset is in the custody of the AtomicMarket account, it is transferred back to the listing's owner. If an expired rental was never wrapped up via the endrent action, the holdership of the asset is reclaimed first. -
- -Clauses: -
-This action may only be called with the permission of the owner of the rental listing, unless the listing is not active and the owner no longer owns the asset (which makes the listing invalid), in which case anyone may call this action. -
- - - - -

rentasset

- ---- -spec_version: "0.2.0" -title: Rent an asset -summary: '{{nowrap renter}} rents the asset {{nowrap asset_id}} for {{nowrap rental_hours}} hours' -icon: https://atomicassets.io/image/logo256.png#108AEE3530F4EB368A4B0C28800894CFBABF46534F48345BF6453090554C52D5 ---- - -Description: -
-{{renter}} rents the asset with the ID {{asset_id}} for {{rental_hours}} hours. - -If the price per hour of the listing differs from {{expected_price_per_hour}}, the transaction fails. - -The total rental price (price per hour times hours, converted to the listing's settlement symbol if the listing uses a delphi pairing, using the delphioracle median price {{intended_delphi_median}} where applicable) is deducted from {{renter}}'s balance. - -The marketplaces facilitating the listing and this rental, and the collection's royalty recipients (according to the collection's royalty split configuration, or the collection author if none exists) each get their share of the rental payment added to their balances; the remainder is paid out to the listing's owner. The collection fee applied is the collection's fee at the time of this rental, regardless of whether it is higher or lower than when the listing was created. - -{{renter}} receives the AtomicAssets HOLDERSHIP of the asset until the end of the rental period; the ownership stays with the AtomicMarket account. The rental period does not renew automatically. - -If {{renter}} already holds an active rental for this asset, the purchased hours extend the current rental period instead. The combined remaining period must stay within the listing's maximum rental duration. - -{{#if taker_marketplace}}The marketplace with the name {{taker_marketplace}} facilitates this rental. -{{else}}The default marketplace facilitates this rental. -{{/if}} -
- -Clauses: -
-This action may only be called with the permission of {{renter}}. -
- - - - -

endrent

- ---- -spec_version: "0.2.0" -title: Wrap up an expired rental -summary: 'The expired rental of the asset {{nowrap asset_id}} is wrapped up' -icon: https://atomicassets.io/image/logo256.png#108AEE3530F4EB368A4B0C28800894CFBABF46534F48345BF6453090554C52D5 ---- - -Description: -
-The rental period of the asset with the ID {{asset_id}} is over, and the AtomicAssets holdership of the asset is moved from the renter back to the AtomicMarket account, making the listing rentable again. - -This action has no effect other than resetting an expired rental back to its listed state. -
- -Clauses: -
-This action may be called by anyone. -
- - - -

lognewsale

--- @@ -1360,72 +1220,6 @@ This action may only be called by the AtomicMarket contract itself. -

lognewrent

- ---- -spec_version: "0.2.0" -title: Log a new rental listing -summary: 'Logs the creation of the rental listing for the asset {{nowrap asset_id}}' -icon: https://atomicassets.io/image/logo256.png#108AEE3530F4EB368A4B0C28800894CFBABF46534F48345BF6453090554C52D5 ---- - -Description: -
-Logs the creation of the rental listing for the asset with the ID {{asset_id}}. This action is only used for notification purposes and has no effect on any state. -
- -Clauses: -
-This action may only be called by the AtomicMarket contract itself. -
- - - - -

logrentstart

- ---- -spec_version: "0.2.0" -title: Log a rental listing becoming active -summary: 'Logs that the rental listing for the asset {{nowrap asset_id}} has become active' -icon: https://atomicassets.io/image/logo256.png#108AEE3530F4EB368A4B0C28800894CFBABF46534F48345BF6453090554C52D5 ---- - -Description: -
-Logs that the rental listing for the asset with the ID {{asset_id}} has become active (the asset has been transferred into the custody of the AtomicMarket account). This action is only used for notification purposes and has no effect on any state. -
- -Clauses: -
-This action may only be called by the AtomicMarket contract itself. -
- - - - -

logrental

- ---- -spec_version: "0.2.0" -title: Log an executed rental -summary: 'Logs that {{nowrap renter}} rented the asset {{nowrap asset_id}}' -icon: https://atomicassets.io/image/logo256.png#108AEE3530F4EB368A4B0C28800894CFBABF46534F48345BF6453090554C52D5 ---- - -Description: -
-Logs that {{renter}} rented the asset with the ID {{asset_id}} from {{lister}} for {{rental_hours}} hours, paying {{paid_settlement_price}}. The rental period ends at {{rental_end}} (seconds since epoch). This action is only used for notification purposes and has no effect on any state. -
- -Clauses: -
-This action may only be called by the AtomicMarket contract itself. -
- - - -

logroyfound

--- diff --git a/src/atomicmarket.cpp b/src/atomicmarket.cpp index 2538c52..f6b3872 100644 --- a/src/atomicmarket.cpp +++ b/src/atomicmarket.cpp @@ -484,8 +484,8 @@ ACTION atomicmarket::withdraw( /** * Creates or updates the royalty split config for a collection * -* The founders category is a list of global recipients that applies to every sale / rental of -* the collection. The three split weights determine how the collection fee is divided between +* The founders category is a list of global recipients that applies to every settlement +* (sale, auction, buyoffer) of the collection. The three split weights determine how the collection fee is divided between * the founders, template and attribute categories. Categories without payees at settlement * time are renormalized away, so the weights only need to be relative to each other. * @@ -641,7 +641,7 @@ ACTION atomicmarket::deltemplroy( /** * Creates or updates an attribute royalty rule for a collection * -* A rule matches when an asset that is sold / rented has an attribute with the exact +* A rule matches when a settled asset (sale, auction, buyoffer) has an attribute with the exact * (source, field, value) triple of the rule. The value's type is part of the match - * uint32_t(5) and int32_t(5) are different keys. If a rule for the exact triple already * exists, its weight and recipients are updated instead of a new rule being created. @@ -1794,326 +1794,6 @@ 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" -* -* 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 -* exchange rate at the time of renting -* -* maximum_rental_duration is in seconds and is the longest period a single rental (including -* extensions by the same renter) can cover -* -* @required_auth lister -*/ -ACTION atomicmarket::announcerent( - name lister, - uint64_t asset_id, - asset price_per_hour, - symbol settlement_symbol, - uint32_t maximum_rental_duration, - name maker_marketplace -) { - require_auth(lister); - - check(price_per_hour.is_valid(), "Invalid type price_per_hour"); - check(settlement_symbol.is_valid(), "Invalid type settlement_symbol"); - - check(price_per_hour.amount > 0, "The price per hour must be greater than zero"); - - check(maximum_rental_duration >= 3600, - "The maximum rental duration must be at least one hour (3600 seconds)"); - check(maximum_rental_duration <= 2419200, - "The maximum rental duration can't be longer than 28 days"); - - name assets_collection_name = get_collection_and_check_assets(lister, vector {asset_id}); - - auto rentals = get_rentals(); - check(rentals.find(asset_id) == rentals.end(), - "A rental listing for this asset already exists"); - - if (price_per_hour.symbol == settlement_symbol) { - check(is_symbol_supported(price_per_hour.symbol), "The specified listing symbol is not supported."); - } else { - check(is_symbol_pair_supported(price_per_hour.symbol, settlement_symbol), - "The specified listing - settlement symbol combination is not supported"); - } - - check(is_valid_marketplace(maker_marketplace), "The maker marketplace is not a valid marketplace"); - - double collection_fee = get_collection_fee(assets_collection_name); - check(collection_fee <= atomicassets::MAX_MARKET_FEE, - "The collection fee is too high. This should have been prevented by the atomicassets contract"); - - 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; - }); - - action( - permission_level{get_self(), name("active")}, - get_self(), - name("lognewrent"), - make_tuple( - asset_id, - lister, - price_per_hour, - settlement_symbol, - maximum_rental_duration, - maker_marketplace, - assets_collection_name, - collection_fee - ) - ).send(); -} - - -/** -* Cancels a rental listing -* -* 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) -* -* 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 -* -* @required_auth The listing's owner (see above for the invalid listing exception) -*/ -ACTION atomicmarket::cancelrent( - uint64_t asset_id -) { - auto rentals = get_rentals(); - auto rental_itr = rentals.require_find(asset_id, - "No rental listing with this asset_id exists"); - - if (!rental_itr->asset_transferred) { - 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); -} - - -/** -* Rents an asset for the specified number of hours -* -* The total price (price per hour x hours, converted to the settlement symbol if the listing -* 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 -* -* 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 -* maximum rental duration) -* -* @required_auth renter -*/ -ACTION atomicmarket::rentasset( - name renter, - uint64_t asset_id, - uint32_t rental_hours, - asset expected_price_per_hour, - uint64_t intended_delphi_median, - name taker_marketplace -) { - require_auth(renter); - - check(expected_price_per_hour.is_valid(), "Invalid type expected_price_per_hour"); - - auto rentals = get_rentals(); - 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, - "The price per hour of this listing differs from the expected price per hour"); - - check(rental_hours > 0, "rental_hours must be at least 1"); - - 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; - - check(!has_active_rental || 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; - - check(new_rental_end - current_time <= 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"); - - __uint128_t total_listing_amount = (__uint128_t) rental_itr->price_per_hour.amount * rental_hours; - check(total_listing_amount <= (__uint128_t) asset::max_amount, "The total rental price is too large"); - - asset listing_price = asset((int64_t) total_listing_amount, rental_itr->price_per_hour.symbol); - - asset settlement_price = calc_settlement_price( - listing_price, - rental_itr->settlement_symbol, - intended_delphi_median - ); - check(settlement_price.amount > 0, "The total rental price must be greater than zero"); - - internal_decrease_balance(renter, settlement_price); - - uint64_t rental_counter_id = consume_counter(name("rental")); - - internal_payout_sale( - settlement_price, - rental_itr->owner, - rental_itr->maker_marketplace, - taker_marketplace, - rental_itr->collection_name, - vector {asset_id}, - get_self(), // the asset is in contract custody - name("rental"), - rental_counter_id, - "AtomicMarket Rental Payout - ID #" + to_string(rental_counter_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(); - } - } - - 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, - asset_id, - rental_itr->owner, - renter, - rental_hours, - settlement_price, - (uint32_t) new_rental_end, - taker_marketplace - ) - ).send(); -} - - -/** -* Wraps up an expired rental by moving the holdership of the asset back to the atomicmarket -* contract, making the listing rentable again -* -* This can be called by anyone - it only resets an expired rental back to its listed state -* -* @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"); - - check(rental_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; - }); -} - - /** * Pays the RAM cost for an already existing sale */ @@ -2183,29 +1863,6 @@ ACTION atomicmarket::paybuyoram( } -/** -* Pays the RAM cost for an already existing rental listing -*/ -ACTION atomicmarket::payrentram( - name payer, - uint64_t asset_id -) { - require_auth(payer); - - auto rentals = get_rentals(); - auto rental_itr = rentals.require_find(asset_id, - "No rental listing with this asset_id exists"); - - rentals_s rental_copy = *rental_itr; - - rentals.erase(rental_itr); - - rentals.emplace(payer, [&](auto &_rental) { - _rental = rental_copy; - }); -} - - /** * This function is called when a transfer receipt from any token contract is sent to the atomicmarket contract * It handels deposits and adds the transferred tokens to the sender's balance table row @@ -2227,7 +1884,7 @@ void atomicmarket::receive_token_transfer(name from, name to, asset quantity, co /** * This function is called when a "transfer" action receipt from the atomicassets contract is sent to the atomicmarket -* contract. It handles receiving assets for auctions and rentals. +* contract. It handles receiving assets for auctions. */ void atomicmarket::receive_asset_transfer( name from, @@ -2280,35 +1937,6 @@ 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 { check(false, "Invalid memo"); } @@ -2450,46 +2078,6 @@ ACTION atomicmarket::logauctstart( require_auth(get_self()); } -ACTION atomicmarket::lognewrent( - uint64_t asset_id, - name lister, - asset price_per_hour, - symbol settlement_symbol, - uint32_t maximum_rental_duration, - name maker_marketplace, - name collection_name, - double collection_fee -) { - require_auth(get_self()); - - 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 asset_id, - name lister, - name renter, - uint32_t rental_hours, - asset paid_settlement_price, - uint32_t rental_end, - name taker_marketplace -) { - require_auth(get_self()); - - require_recipient(lister); - require_recipient(renter); -} - // 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 @@ -3005,7 +2593,7 @@ void atomicmarket::internal_payout_sale( // Collection fee - the fee at EXECUTION time ALWAYS applies, regardless of the fee that // was stored when the listing was created. This gives the collection author full control: // both fee reductions and fee raises take effect immediately on every already-created - // listing (sales, auctions and rentals alike). The stored collection_fee is retained only + // listing (sales, auctions and buyoffers alike). The stored collection_fee is retained only // for informational / indexing purposes (emitted by the lognew* actions) and no longer // influences the payout. // The same partial read also provides the author for the royalty distribution. diff --git a/tests/auctions.test.js b/tests/auctions.test.js index e1a3360..6277498 100644 --- a/tests/auctions.test.js +++ b/tests/auctions.test.js @@ -36,14 +36,12 @@ describe('atomicmarket auctions', () => { const aaTables = { assets: (scope) => atomicassets.tables.assets(nameToBigInt(Name.from(scope))).getTableRows(), - holders: () => atomicassets.tables.holders(nameToBigInt(atomicassets.name)).getTableRows(), offers: () => atomicassets.tables.offers(nameToBigInt(atomicassets.name)).getTableRows(), }; const marketTables = { balances: () => atomicmarket.tables.balances(nameToBigInt(atomicmarket.name)).getTableRows(), sales: () => atomicmarket.tables.sales(nameToBigInt(atomicmarket.name)).getTableRows(), auctions: () => atomicmarket.tables.auctions(nameToBigInt(atomicmarket.name)).getTableRows(), - rentals: () => atomicmarket.tables.rentals(nameToBigInt(atomicmarket.name)).getTableRows(), }; const balanceOf = (account) => { @@ -154,7 +152,6 @@ describe('atomicmarket auctions', () => { // The chain clock only moves on addTime (actions do not advance it), and auctions are // announced with a 600s duration, so a single 700s jump finishes the latest auction. - // This is the same time-advance technique market-smoke uses for rental expiry. const finishAuctions = () => blockchain.addTime(TimePoint.fromMilliseconds(700 * 1000)); // Inject an auction row directly into the table store (mirrors market-smoke's legacy-row diff --git a/tests/market-smoke.test.js b/tests/market-smoke.test.js index 39bab48..6763522 100644 --- a/tests/market-smoke.test.js +++ b/tests/market-smoke.test.js @@ -5,14 +5,12 @@ const fs = require('fs'); /* * End-to-end smoke suite for the atomicmarket contract. * - * Covers the three workstreams: + * Covers the two workstreams: * 1. Notification dispatch (the on_notify handlers): proves atomicassets::transfer routes to * receive_asset_transfer (and NOT the *::transfer token handler), token transfers route to * 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. */ const WAX = (amount) => `${amount.toFixed(8)} WAX`; @@ -30,14 +28,12 @@ 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(), offers: () => atomicassets.tables.offers(nameToBigInt(atomicassets.name)).getTableRows(), }; const marketTables = { balances: () => atomicmarket.tables.balances(nameToBigInt(atomicmarket.name)).getTableRows(), sales: () => atomicmarket.tables.sales(nameToBigInt(atomicmarket.name)).getTableRows(), auctions: () => atomicmarket.tables.auctions(nameToBigInt(atomicmarket.name)).getTableRows(), - rentals: () => atomicmarket.tables.rentals(nameToBigInt(atomicmarket.name)).getTableRows(), }; const balanceOf = (account) => { @@ -367,30 +363,6 @@ describe('atomicmarket end to end', () => { expect(aaTables.assets('buyer').length).toBe(1); }); - test('delphi-priced rental settles in WAX at the oracle rate', async () => { - await setupDelphiPair(); - - 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); - - // 2 hours x 0.50 USD = 1.00 USD = 20 WAX - await atomicmarket.actions.rentasset([ - 'renter', ASSET1, 2, '0.50 USD', 500, '', - ]).send('renter@active'); - - expect(balanceOf('author')).toEqual([WAX(2)]); // 10% of 20 WAX - 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'); - }); - test('lowering the collection fee after listing discounts the executed sale', async () => { await listAndActivateSale([ASSET1], 1); await deposit('buyer', 1); @@ -684,219 +656,6 @@ describe('atomicmarket end to end', () => { expect(aaTables.assets('seller').length).toBe(2); // assets never moved }); - /* ------------------------------------------------------------------ */ - /* 3. Rentals */ - /* ------------------------------------------------------------------ */ - - 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 () => { - await listAndActivateRental(); - - let 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); - - // rent for 2 hours = 1.0 WAX - await deposit('renter', 2); - await atomicmarket.actions.rentasset([ - '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 }, - ]); - - // payout: 1.0 WAX -> 2% fees.atomic, 10% author, 88% straight to the seller - expect(balanceOf('author')).toEqual([WAX(0.1)]); - expect(balanceOf('fees.atomic')).toEqual([WAX(0.02)]); - const sellerTokens = token.tables.accounts(nameToBigInt(Name.from('seller'))).getTableRows(); - expect(sellerTokens).toEqual([{ balance: WAX(0.88) }]); - - // someone else can't rent while active - await deposit('renter2', 2); - await expect( - atomicmarket.actions.rentasset([ - 'renter2', ASSET1, 1, WAX(0.5), 0, '', - ]).send('renter2@active') - ).rejects.toThrow(/currently rented out/); - - // owner can't cancel while active - await expect( - atomicmarket.actions.cancelrent([ASSET1]).send('seller@active') - ).rejects.toThrow(/currently rented out/); - - // 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); - await atomicmarket.actions.rentasset([ - 'renter', ASSET1, 1, WAX(0.5), 0, '', - ]).send('renter@active'); - expect(Number(marketTables.rentals()[0].rental_end)).toBe(endBefore + 3600); - - // jump past the rental end; anyone can wrap it up - 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); - - // owner cancels; the asset comes back - 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 () => { - 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 - 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'); - }); - - test('cancelrent on an expired-but-not-ended rental reclaims holdership and the asset', 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)); - - await atomicmarket.actions.cancelrent([ASSET1]).send('seller@active'); - - expect(aaTables.holders()).toEqual([]); - expect(marketTables.rentals()).toEqual([]); - expect(aaTables.assets('seller').length).toBe(2); - }); - - test('rental payouts respect royalty splits', async () => { - await setupRoyaltySplits(); - await listAndActivateRental(); - - await deposit('renter', 1); - await atomicmarket.actions.rentasset([ - 'renter', ASSET1, 2, WAX(0.5), 0, '', - ]).send('renter@active'); - - // identical math to the sale split test (1.0 WAX total, 0.1 collection cut) - expect(balanceOf('founder1')).toEqual(['0.00833333 WAX']); - expect(balanceOf('founder2')).toEqual(['0.02499999 WAX']); - expect(balanceOf('temproy1')).toEqual(['0.03333333 WAX']); - expect(balanceOf('attrroy1')).toEqual(['0.03333333 WAX']); - expect(balanceOf('author')).toEqual(['0.00000002 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 - await atomicassets.actions.setmarketfee([COL, 0.05]).send('author@active'); - - await deposit('renter', 1); - await atomicmarket.actions.rentasset([ - 'renter', ASSET1, 2, WAX(0.5), 0, '', - ]).send('renter@active'); - - // 1.0 WAX rental: author gets the discounted 5%, not the stored 10% - expect(balanceOf('author')).toEqual([WAX(0.05)]); - const sellerTokens = token.tables.accounts(nameToBigInt(Name.from('seller'))).getTableRows(); - expect(sellerTokens).toEqual([{ balance: WAX(0.93) }]); - }); - - test('payrentram switches the RAM payer without changing the listing', async () => { - await listAndActivateRental(); - - const before = marketTables.rentals(); - await atomicmarket.actions.payrentram(['buyer', ASSET1]).send('buyer@active'); - expect(marketTables.rentals()).toEqual(before); - }); - - test('rental listing validation', async () => { - // unsupported symbol - await expect( - atomicmarket.actions.announcerent([ - 'seller', ASSET1, '1.0000 EOS', '4,EOS', 86400, '', - ]).send('seller@active') - ).rejects.toThrow(/not supported/); - - // duration under an hour - await expect( - atomicmarket.actions.announcerent([ - 'seller', ASSET1, WAX(0.5), '8,WAX', 1800, '', - ]).send('seller@active') - ).rejects.toThrow(/at least one hour/); - - // not the asset owner - await expect( - atomicmarket.actions.announcerent([ - 'buyer', ASSET1, WAX(0.5), '8,WAX', 86400, '', - ]).send('buyer@active') - ).rejects.toThrow(/does not own/); - - await listAndActivateRental(); - - // owner can't rent their own asset - await expect( - atomicmarket.actions.rentasset([ - 'seller', ASSET1, 1, WAX(0.5), 0, '', - ]).send('seller@active') - ).rejects.toThrow(/own asset/); - - // exceeding the maximum duration (86400s = 24h) - await deposit('renter', 100); - await expect( - atomicmarket.actions.rentasset([ - 'renter', ASSET1, 25, WAX(0.5), 0, '', - ]).send('renter@active') - ).rejects.toThrow(/maximum rental duration/); - - // price expectation mismatch - await expect( - atomicmarket.actions.rentasset([ - 'renter', ASSET1, 1, WAX(0.6), 0, '', - ]).send('renter@active') - ).rejects.toThrow(/differs from the expected price/); - }); - /* ------------------------------------------------------------------ */ /* 4. Default marketplace creator + balance migration (XPR) */ /* ------------------------------------------------------------------ */