Skip to content

Experimental: non-custodial renter-as-owner rental primitives - #26

Draft
robrigo wants to merge 12 commits into
feat/v2-integrationfrom
experiment/noncustodial-rentals
Draft

Experimental: non-custodial renter-as-owner rental primitives#26
robrigo wants to merge 12 commits into
feat/v2-integrationfrom
experiment/noncustodial-rentals

Conversation

@robrigo

@robrigo robrigo commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Experimental branch for smart-contract review. Pairs with atomicassets/atomicmarket-contract#11 (the orchestration side); review the two together.

Summary

Reworks V2 rentals from custodial to non-custodial. In the shipped model a lease moves the asset into atomicmarket and the renter is only a holder; because the ecosystem keys on owner, a rented asset gives the renter no utility. Here the renter becomes the real AtomicAssets owner for the lease term, the lister's reclaim right is recorded in a leases row, and the asset is locked until a guaranteed return at expiry. Unmodified consumers (games, the public API, drops, snapshots) see the renter as owner with no changes.

Design

  • leases table { asset_id, title_owner, renter, collection_name, rental_start, rental_end, rental_id }, keyed by asset_id. A row's existence means the asset is locked, and the table is the single source of truth for lock state. rental_id is an opaque market-side id (AtomicMarket's rental counter), stored at lease-open and echoed in the lease logs so indexers can join a reclaim to the rental that opened the lease structurally instead of parsing memo text.
  • Lock: check_not_leased guards every renter-reachable extraction path. It runs inside internal_transfer (the chokepoint for transfer and acceptoffer, via an enforce_lock flag) and directly in burnasset and createoffer. setassetdata is left unguarded by design, since it is collection-auth gated and never renter-reachable.
  • Lifecycle: leasestart flips ownership from lister to renter, writing the lease row before the move so there is no unlocked window; leaseextend bumps the end time; permissionless reclaim returns the asset to the title_owner at expiry under the contract's own authority, so it needs no renter signature. loglock and logreclaim emit traces for indexers: loglock carries rental_start (so a lease-open, where rental_start == now, is distinguishable from an extension without a table read) and both carry the rental_id.
  • Authority: a single configured rental_market (the rentalcfg singleton) gates leasestart and leaseextend. Leasing is opt-in: rental_market defaults to name("") (disabled), so a fresh deploy stays off until governance calls setrentmkt("atomicmarket"), and setting it back to name("") disables all leasing. Lister consent is captured upstream by AtomicMarket's announcerent.
  • Duration cap: leasestart and leaseextend cap a lease, and its total extended window measured from the fixed rental_start, at a governance-settable max_lease_seconds (setleasecap, contract authority), which is itself bounded by the compile-time 28-day MAX_LEASE_SECONDS protocol ceiling. This bounds the impact if the configured market is ever compromised. The singleton's field set is deliberately finalized now, since it freezes at its first mainnet write.

Removed

The custodial machinery: the move action, the holders table and get_holders(), and logmove.

Tests

VeRT: 41 suites / 348 pass (1 skipped). tests/asset-actions/renting-invariants.test.js covers the lock guards on every path, the deliberate setassetdata non-guard, the lifecycle (including the fixed rental_start/rental_id across extensions), the configured-market authority, the opt-in default, the duration cap, and the setleasecap bounds and cross-preservation with setrentmkt. A fixture contract proves a hostile renter contract cannot abort the permissionless reclaim.

Notes for reviewers

  • reclaim notifies only the asset's collection, not the renter or title_owner, so a hostile renter cannot abort the guaranteed return by throwing in a notification handler. A collection notify-account can still abort it, which is accepted under the same trust model that already lets a collection gate transfers of its assets. (Corollary: a collection notify contract that throws on loglock selectively vetoes lease creation for its assets — a de facto per-collection opt-out.)
  • The market account is configurable rather than hardcoded. setrentmkt already requires the contract's own authority (the same authority as an upgrade), so hardcoding would not reduce trust, and the market account name can differ per chain. One AtomicAssets + one AtomicMarket per network is a design invariant, so no multi-market machinery exists or is planned.
  • rental_id is fully opaque to this contract: no checks, no index, 8 bytes of market-paid row RAM. It exists because the required indexer follow-on (nft-data / eosio-contract-api) needs a structural reclaim-to-rental join, and log/table shapes are free to change only while the tables are empty.
  • The leaseextend cap anchors to the fixed rental_start while a reclaim + re-lease gets a fresh window: deliberate asymmetry, since a re-lease necessarily transits the reclaimable state the cap exists to guarantee.

Replace the custodial holders/move machinery with a native lock + title
model. During a lease the renter becomes the real AtomicAssets owner, the
lister's reclaim right is parked in a `leases` row, and the asset is locked
from transfer/burn/offer-out while that row exists. A permissionless
`reclaim` force-returns the asset to the title_owner at expiry, so neither
party can strand it.

- remove the move action, holders table and logmove
- add the leases table + check_not_leased guards on internal_transfer
  (chokepoint, covers transfer + acceptoffer), burnasset and createoffer;
  setassetdata is deliberately left unguarded (collection-auth gated)
- add pretitle/leasestart/leaseextend/delpretitle/reclaim, a config'd
  rental_market authority gate (setrentmkt, defaults to atomicmarket) and
  loglock/logreclaim log actions; offers are cleared on lease-start/reclaim
- replace the custodial renting characterization tests with a non-custodial
  lease/lock suite

Experimental branch for smart-contract review.
… singleton)

- Drop pretitle/delpretitle and the sentinel lease state. leasestart is always
  the direct trusted-market path (lister consent is captured by AtomicMarket's
  announcerent). A leases row now always means an active lease.
- Store the configured rental market in a new rentalcfg singleton (default
  atomicmarket, read via get_or_default) instead of appending to config_s, so
  deploying onto the existing live contract needs no config migration.
- Remove offer-clearing on lease-start/reclaim. A pre-existing offer now
  survives a rental (it cannot settle while the asset is locked) and becomes
  acceptable again after reclaim. This also removes a renter-controlled
  unbounded loop on the permissionless reclaim path.
- Add a check_rental_market helper; remove the dead renter=="" re-checks.
- Update the VeRT suite (lease lifecycle, authority, reclaim, offer survival).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR prototypes non-custodial “renter-as-owner” rental primitives in the AtomicAssets contract by replacing the legacy custodial holder/move model with an on-chain lease lock (leases) that prevents renter-controlled extraction while enabling permissionless reclaim after expiry.

Changes:

  • Removes the custodial move action and holders table, and introduces leases + rentalcfg (configured rental-market authority) plus new lifecycle actions (leasestart, leaseextend, reclaim, setrentmkt).
  • Enforces a rental lock across renter-reachable extraction paths (transfer via internal_transfer, burnasset, createoffer), and adds loglock/logreclaim for indexers.
  • Updates tests to drop holders/move behavior and add a dedicated non-custodial rental invariants suite.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/Transfer-Offer Actions/transfer.test.js Removes holder-record transfer tests tied to the deleted custodial holders model.
tests/Deposit-Withdraw-Back-Burn Actions/burnasset.test.js Removes holder-record burn test tied to the deleted holders model.
tests/Asset Actions/renting-invariants.test.js Replaces characterization tests with non-custodial rental lifecycle/lock/reclaim/authority tests.
tests/Asset Actions/move.test.js Deletes the move action test suite (action removed).
src/atomicassets.cpp Implements rentalcfg singleton + leasestart/leaseextend/reclaim, lock enforcement, and new log actions; removes holders logic.
include/atomicassets.hpp Exposes new actions, tables, and helpers; replaces holders accessors with leases accessors; updates internal_transfer signature.
include/atomicassets-interface.hpp Updates the external interface view of the contract tables from holders to leases.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/atomicassets.cpp Outdated
Comment on lines 179 to 184
check_rental_market(market);

if (holders_itr != holders.end()){
if (holders_itr->holder != from){
check(false,
("At least one asset invalidates the 'from:holder' constraint (ID: " + to_string(asset_id) + ")").c_str());
}
leases_t leases = get_leases();
auto lease_itr = leases.require_find(asset_id, "Asset is not leased");
check(rental_end > lease_itr->rental_end, "rental_end must be later than the current end");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in 5a89ff1: leaseextend now requires now < rental_end, so an expired lease can only be reclaimed, never extended. The configured market can no longer race the permissionless reclaim. AtomicMarket only ever extends an active lease, so the normal flow is unaffected; added a VeRT regression for the post-expiry case.

robrigo added 5 commits June 28, 2026 21:58
…eriment/noncustodial-rentals

# Conflicts:
#	include/atomicassets-interface.hpp
#	tests/asset-actions/move.test.js
#	tests/asset-actions/renting-invariants.test.js
leaseextend only required the new end to be later than the current one, so once
a lease had expired the configured market could race the permissionless reclaim
and push rental_end into the future, indefinitely blocking the guaranteed revert
to the title_owner. Require the lease to still be active (now < rental_end);
an expired lease can only be reclaimed. AtomicMarket already only extends an
active lease, so this does not affect the normal flow. Adds a VeRT regression.
…ot rolling

Add rental_start to the leases table, set when the lease is first opened
(leasestart) and left unchanged across extensions (leaseextend). This lets
AtomicMarket cap the total rental period from the original start rather than a
rolling window from "now". Action signatures are unchanged; only the leases
table gains a field.
A leases row always represents an active lease now (pretitle/sentinels were
removed), so renter is never empty. Drop the `if (renter != name(""))` guards
and always notify the renter.
With a single configured rental market, passing and storing a market identity
is redundant. leasestart/leaseextend no longer take a market argument; authority
is simply require_auth(rentalcfg.rental_market) via check_rental_market(), which
returns the configured account for use as the lease row's RAM payer. Drop the
leases.market column and the market field from loglock. Tests updated; the
authority tests now assert the missing-required-authority path.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comment thread include/atomicassets.hpp
Comment on lines +464 to +468
TABLE leases_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; };
name title_owner; // lister; reclaim returns the asset here
name renter; // current AA owner during the lease
uint32_t rental_start; // sec_since_epoch the lease was first opened (fixed across extensions)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the PR description: the leases row is {asset_id, title_owner, renter, rental_start, rental_end} with no market field. The single rental market lives in the rentalcfg singleton (set via setrentmkt), so it isn't stored per-lease. Code was correct; the description was stale.

Comment thread src/atomicassets.cpp
Comment on lines 1868 to +1870
string memo,
name scope_payer
name scope_payer,
bool enforce_lock

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in ad8fb43. The comment now notes the get_self() exception: a contract can always bill its own RAM, so the privileged leasestart/reclaim paths pass scope_payer=get_self() and need no title_owner/renter signature to create the destination scope.

robrigo added 2 commits June 28, 2026 23:17
The non-custodial model rests on reclaim always returning the asset to the
lister at expiry. logreclaim notified the renter via require_recipient, so a
renter that is a contract could throw in its handler and abort every reclaim,
keeping the asset forever (the old `move` already warned that from/to
notifications are "exploitable"). Drop the renter and title_owner recipients
from logreclaim. The asset's collection is still notified (on logtransfer and
logreclaim), trusting collections not to grief their own collection.

Also add a MAX_LEASE_SECONDS (28d) protocol backstop in leasestart/leaseextend
so a compromised or buggy rental market can't mint a near-permanent lock; the
market keeps its own product cap on top.

Tests: new evil-renter fixture proves the renter cannot veto reclaim while the
collection still is; plus duration-cap rejections.
@robrigo

robrigo commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

Security review — non-custodial reclaim hardening

Audited the reclaim/lock machinery (paired with atomicassets/atomicmarket-contract#11). Lock coverage, inline-action atomicity, royalty scope, and extension capping all check out. One Critical and one High issue were found and are fixed in the latest commit (1e6579d).

🔴 Critical — permissionless reclaim was renter-vetoable (FIXED)

logreclaim notified the renter via require_recipient. In Antelope a notified contract that throws aborts the whole transaction, so a renter that is a contract could accept loglock (receive the asset at lease start) but throw on logreclaim, aborting every reclaim attempt — keeping the rented asset forever for the price of one rental. That defeats the guaranteed-revert invariant the whole model rests on. (The old custodial move already warned that from/to notifications are "exploitable.")

Fix: dropped require_recipient(renter) and require_recipient(title_owner) from logreclaim. The asset's collection is still notified (via logtransfer and logreclaim) so collections can react to their assets returning — accepted under the trust model that a collection won't grief its own collection (the same abort power collections already have over transfers). The renter — an arbitrary account that profits from keeping the asset — is the one party that must never hold a veto over the revert.

Regression test: new evil-renter fixture (throws on logreclaim) proves the renter cannot block reclaim, while a collection notify-account is still reached. Verified the protection genuinely fails if the renter recipient is restored.

🟠 High — leasestart had no protocol duration cap (FIXED)

The AA primitive accepted any rental_end up to ~year 2106 and delegated full asset-movement authority to the configured market (no title_owner co-sign). A compromised or buggy market could mint a near-permanent lock on any asset. Fix: MAX_LEASE_SECONDS (28d) backstop in leasestart (measured from now) and leaseextend (from the fixed rental_start, so repeated extensions can't roll forward indefinitely). AtomicMarket keeps its own 28d product cap on top.

🟡 Medium (your call) — sale + rental coexistence

Not changed (orchestration lives in #11). An unleased asset can be both sale-listed and rent-announced; renting it then blocks the standing sale until reclaim — atomic, no fund loss, but a cheap front-run/grief on a pending purchase. Decide: refuse cross-listing, surface lease-state in the purchase error, or accept-and-document with the cancel-invalid-rentals cron.

🟢 Low

Market-funded lease-row RAM (resolved by the Critical fix); uint32 rental_end Y2106 truncation (not reachable under the 28d cap).

Tests green: AA 41 suites / 346 pass.

Post-review cleanup of the non-custodial lease primitives (pairs with
atomicmarket-contract#11):

- Leasing is now opt-in: rentalcfg defaults to name("") (disabled) instead of
  atomicmarket, so a fresh deploy is off until governance calls setrentmkt;
  setrentmkt(name("")) stays the kill-switch.
- Denormalize collection_name onto the leases row, removing the unreachable
  "renter no longer owns the asset" refetch in leaseextend/reclaim.
- Extract send_loglock to drop the duplicated loglock emit.
- Trim the verbose rental comments to the house style: consolidate the
  reclaim-veto rationale into logreclaim, drop em-dashes and the instructional
  error string, reuse the collection_name local.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.

Comment on lines +43 to +44
// The rentalcfg singleton defaults to "atomicmarket", so create that
// account as the authorized market (no setrentmkt needed).
Comment thread include/atomicassets.hpp
Comment on lines 469 to +473
uint64_t asset_id;
name holder;
name owner;
name title_owner; // lister; reclaim returns the asset here
name renter; // current AA owner during the lease
name collection_name;
uint32_t rental_start; // sec_since_epoch the lease was first opened (fixed across extensions)
Comment thread include/atomicassets.hpp
Comment on lines +526 to +532
// The single account authorized to open/manage leases (leasestart/leaseextend), in its own
// singleton so it needs no config migration. Leasing is opt-in: name("") (the default, and an
// absent row) means disabled, so a fresh deploy is off until setrentmkt("atomicmarket"); set it
// back to name("") to kill-switch all leasing. Not hardcoded - the market account differs per chain.
TABLE rentalcfg_s {
name rental_market = name("");
};
let owner; // asset owner / lessor
let holder; // current holder / lessee
let third; // unrelated third party
let market; // configured rental market (rentalcfg default = "atomicmarket")
robrigo added 2 commits June 30, 2026 16:57
… review)

The test comments still said rentalcfg defaults to "atomicmarket"; it now
defaults to disabled (opt-in), and the suite enables leasing via setrentmkt.
… cap

Greenfield hardening from the design review - table shapes and log ABIs
are free to change now (no mainnet rows) and near-impossible later:

- leases row + leasestart carry an opaque market-side rental_id, echoed
  in loglock and logreclaim, so indexers can join a reclaim to the rental
  that opened the lease structurally instead of parsing memo text
- loglock now carries rental_start: consumers can compute duration and
  tell a lease start (rental_start == now) from an extension without a
  table read that races the trace
- rentalcfg gains a governance-settable max_lease_seconds (setleasecap),
  bounded above by the compile-time MAX_LEASE_SECONDS protocol ceiling;
  the singleton's field set freezes at its first mainnet write, so it is
  decided now. setrentmkt and setleasecap preserve each other's fields

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

robrigo added a commit that referenced this pull request Jul 3, 2026
Custodial rentals are descoped from the V2 release so the rest of V2 can
ship without them. The dual-ownership mechanism is removed in full:

- move action and logmove notification
- holders table (+ holders_s struct, get_holders accessor) in both the
  contract header and the consumer-facing interface header
- holder-erase block in burnasset
- holder bookkeeping in internal_transfer

No other V2 feature reads holder state, so this is a pure excision.
ABI diff vs v2.0.0-rc3: exactly move, logmove, holders(_s) removed.

Rentals live on: the custodial implementation is preserved on
archive/v2-custodial-rentals (and the v2.0.0-rc1..rc3 tags); the
non-custodial rework continues on experiment/noncustodial-rentals (#26).

Tests: move.test.js (16) and renting-invariants.test.js (3) deleted;
holder-specific cases removed from transfer.test.js and burnasset.test.js.
Suite: 40 suites, 324 passing (1 pre-existing skip).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants