Skip to content

feat: list collections through the newest off-chain marketplace - #3473

Open
LautaroPetaccio wants to merge 5 commits into
masterfrom
feat/off-chain-marketplace-v3-listings
Open

feat: list collections through the newest off-chain marketplace#3473
LautaroPetaccio wants to merge 5 commits into
masterfrom
feat/off-chain-marketplace-v3-listings

Conversation

@LautaroPetaccio

@LautaroPetaccio LautaroPetaccio commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Stacked on #3472 (the dependency bump) — review that first; this PR's diff is only the listing changes.
Blocked on a decentraland-dapps release containing decentraland/decentraland-dapps#819. Draft for that reason. The decentraland-transactions 3.1.1 dependency is resolved — 3.1.1 is released and #3472 pins it.

What

Builder grants minter rights to the off-chain marketplace so it can mint the listed item. That was pinned to V2; it now resolves the newest version deployed on the chain, so testnet listings go through V3 while mainnet keeps using V2 until V3 ships there.

  • enableSaleOffchain grants the resolved version
  • PutForSaleOffchainModal labels and authorizes the resolved version instead of hardcoding V2
  • isEnableForSaleOffchain accepts any deployed version as the minter

Why the check has to widen, not switch

The minter check is what makes a collection show as on sale. If it only accepted the newest version, every collection already listed through V1 or V2 would immediately look unlisted — which is exactly what the V1 → V2 rollout had to handle too (37531cbb widened it from V1 to "V1 or V2"). This widens it to "any deployed version", so the next version needs no change here.

The ordering constraint that makes this blocked

The marketplace granted minter rights is the one that actually mints, so it has to be the same version the trade was signed against — otherwise the mint reverts. Signing happens in decentraland-dapps' getTradeSignature, which decentraland/decentraland-dapps#819 moves to the same "newest deployed version" resolution.

So this must not merge before a dapps release carrying that change. If Builder granted V3 rights while the shipped dapps still signed V2 trades, listings would break.

The resolution is duplicated here rather than imported from dapps to keep this PR verifiable on its own, and the code says so — the two definitions have to stay in step. Happy to collapse it onto a dapps export as a follow-up once #819 is released.

Testing

src/modules/collection/utils.spec.ts passes with 36 tests, up from 31. New cases cover resolving to V3 on Amoy, reporting V3 as the modal's contract label, falling back to V2 on Polygon mainnet, and a collection minted by V2 still reading as on sale while V3 is current. tsc and eslint are clean.

I ran every spec that touches the changed helpers, not just this one — utils.spec.ts is the only one.

@vercel

vercel Bot commented Aug 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
builder Ready Ready Preview Sep 2, 2026 7:43pm UTC

Request Review

@coveralls

coveralls commented Aug 20, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 33674668662

Coverage increased (+0.02%) to 53.755%

Details

  • Coverage increased (+0.02%) from the base build.
  • Patch coverage: 21 of 21 lines across 2 files are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 13375
Covered Lines: 7793
Line Coverage: 58.27%
Relevant Branches: 5998
Covered Branches: 2621
Branch Coverage: 43.7%
Branches in Coverage %: Yes
Coverage Strength: 37.36 hits per line

💛 - Coveralls

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed this together with marketplace-squid-core#112, trades-squid-core#43, marketplace-server#395, decentraland-dapps#819, and builder#3472 for the V3 off-chain marketplace rollout.

Requesting changes for one correctness issue in Builder's off-chain sale authorization flow:

  • P1 — enableSaleOffchain() only targets the latest marketplace address for both enabling and disabling. isEnableForSaleOffchain() correctly treats any deployed off-chain marketplace version as an active listing minter, but disabling returns a revoke access for only the latest version. After the V3 rollout, a collection that still has V2 minter rights will continue to be sellable via existing V2 listings even after the user toggles off-chain sale off. Please make the disable path revoke every currently-authorized off-chain marketplace address, while keeping the enable path on the latest/signable marketplace.

Security pass: no secrets or direct injection issues found in this PR.


Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack

@LautaroPetaccio LautaroPetaccio left a comment

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 — that was a real bug and it was mine. Fixed in f413bbe.

Widening isEnableForSaleOffchain() to accept any deployed version without widening the revoke left the toggle half-broken: a collection holding V2 minter rights kept them after disabling, so existing V2 listings stayed sellable, and isEnableForSaleOffchain() still reported the collection as on sale — so the toggle looked like it had done nothing.

Worth noting the original V1 → V2 rollout got this right: 37531cbb revoked whichever version the collection actually held, and d2da9a66 later trimmed it to the single-address one-liner I inherited.

Now:

// Enabling grants the newest version only: the minter has to be the same contract as the
// signature domain or the mint reverts.
if (isOnSale) {
  return [{ address: getLatestOffchainSale(chainId).address, hasAccess: true, collection }]
}

const authorized = getOffchainSaleAddresses(chainId).filter(address => includes(collection.minters, address))
const toRevoke = authorized.length ? authorized : [getLatestOffchainSale(chainId).address]

return toRevoke.map(address => ({ address, hasAccess: false, collection }))

The fallback is deliberate: handleSetCollectionMintersRequest turns this list into the addresses/values arrays for setMinters, so returning [] when a collection holds no off-chain minters would send the contract two empty arrays. Falling back to the newest keeps the previous single-entry shape for that edge case.

Two tests added — that both held versions are revoked, and that a version the collection does not hold is not. 38 passing, tsc and eslint clean.

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I re-reviewed the stacked V3 listing changes after the revoke fix and the decentraland-dapps@29.7.1 bump. The previous revoke-every-held-version issue is fixed, but there is still one blocking signing-path mismatch.

Findings

  • P1 — Builder still signs primary item orders against OffChainMarketplaceV2 locally. enableSaleOffchain(..., true) now authorizes getLatestOffchainSale(chainId), and the added regression checks that this matches decentraland-dapps' getLatestOffChainMarketplaceContract(). However Builder's actual item-order creation still uses the vendored src/lib/trades.ts#getTradeSignature(), which hardcodes getContract(ContractName.OffChainMarketplaceV2, trade.chainId) for the EIP-712 domain. On Amoy, the modal/authorization path can grant V3 minter rights while the created trade signature is still for V2, so the authorized minter and settlement/signature domain can diverge and primary listings can revert at mint. Please update the local signer to use the same latest-marketplace resolver (or remove the vendored signer once dapps supports the required USD-pegged path), and add/adjust a regression test that verifies the contract used by Builder's actual getTradeSignature() matches the address authorized by getLatestOffchainSale().

Security pass: no secrets, auth/authz, input-handling, or direct injection issues found in the changed code.

Consumer impact: no external API surface is changed; this is internal Builder marketplace-selection/signing behavior.

CI status: Vercel and audit are passing; one test job was still pending when checked, while the other test job passed.


Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack

Base automatically changed from chore/bump-decentraland-transactions-3.1.0 to master August 26, 2026 19:13
@LautaroPetaccio
LautaroPetaccio force-pushed the feat/off-chain-marketplace-v3-listings branch from c0b34a1 to e709b57 Compare August 26, 2026 19:13

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed after the latest commit.

The previous P1 signing-path mismatch is fixed: Builder now creates item-order signatures through decentraland-dapps@29.7.1 instead of the vendored src/lib/trades.ts, and the new regression asserts the actual EIP-712 verifying contract matches the marketplace Builder authorizes. I also checked decentraland-dapps@29.7.1: getOffChainMarketplaceContract() now resolves through the same latest-marketplace helper, so the signature indexes and signature domain stay aligned.

No blocking findings.

Non-blocking follow-up suggestion:

  • P2: src/modules/collection/utils.ts still duplicates the latest marketplace resolution order. The regression covers the current V3/V2 rollout, but using the dapps resolver directly for the “latest/signing” path in a future cleanup would reduce drift risk when another marketplace version is introduced.

Security pass: no secrets, auth/authz, input-handling, or direct injection issues found in the changed code.

Consumer impact: no external API surface is changed; this remains internal Builder marketplace-selection/signing behavior.

CI status: passing.


Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack

@decentraland-bot

Copy link
Copy Markdown
Contributor

✅ Approved by Claude, approved by Codex — resolves the off-chain marketplace version dynamically (V3 on testnet, V2 on mainnet) for granting/checking/revoking minter rights, and deletes the vendored trade-signing module in favor of dapps 29.7.1, which carries the USD-pegged fix and the same resolver.

Checked: full diff and changed files; authorization/signing call sites; dependency bump (dapps 29.7.1 tarball verified to export getLatestOffChainMarketplaceContract/getTradeSignature with the USD_PEGGED_MANA case, so deleting the vendored module is safe); existing V2-signed listings stay cancellable via trade.contract; security surface; tests; API/consumer impact; git conventions; CI green on head ce4618b (test ×2, audit ×2, Vercel).

Non-blocking, agreed by both (Claude raised, Codex confirmed with FIX-OK):

  • [P2] src/modules/collection/utils.ts:90 — nothing enforces that the address granted by getLatestOffchainSale is a member of the hardcoded OFFCHAIN_SALE_CONTRACT_NAMES list the check/disable paths walk; a future dapps release adding a newer marketplace version could drift. Suggested: union the chain-resolved latest address into getOffchainSaleAddresses (deduped), and/or add a spec asserting the invariant for MATIC_AMOY and MATIC_MAINNET.
  • [P2] src/lib/manaRate.ts:11 — the docblock still names OffChainMarketplaceV2 while dapps 29.7.1 now resolves the newest deployment; the PR description also still says blocked/draft and that the resolution is duplicated. Suggested: reword the docblock to the version-agnostic phrasing and refresh the PR description.

Cross-model review by Jarvis 🤖 · head ce4618b · Claude + Codex · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack

Grants minter rights to the newest marketplace version deployed on the chain
rather than always V2, so testnet listings go through V3 while mainnet keeps
using V2 until V3 ships there. The authorization modal labels whichever
version was resolved.

isEnableForSaleOffchain now accepts any deployed version as the minter, not
just V1 or V2. Narrowing it to the current version would make every
collection listed through an older marketplace look unlisted.

The resolution deliberately mirrors decentraland-dapps' getTradeSignature and
has to stay in step with it: the marketplace granted minter rights is the one
that mints the item, so it must be the version the trade was signed against.
enableSaleOffchain only revoked the newest version, so a collection holding
V2 minter rights kept them after the user turned off-chain sale off: listings
on that version stayed sellable, and isEnableForSaleOffchain - which accepts
any deployed version - still reported the collection as on sale.

Enabling still grants the newest version only, since the minter has to be the
contract the trade was signed against. Disabling now revokes every version
the collection actually holds, falling back to the newest when it holds none
so the contract never receives empty arrays.
29.7.1 is the first release where getTradeSignature signs against the newest
deployed marketplace rather than V2. Until now Builder granted minter rights to V3
on testnets while dapps still signed the trade against V2, so the authorized minter
was not the contract that settles and a primary listing reverted at mint.

Adds the cross-package regression test promised on #3472: the address Builder
authorizes has to equal the one dapps builds its EIP-712 domain from. Neither repo's
own tests can catch that divergence alone. Verified it fails when the two resolution
orders disagree.
…red copy

Builder authorized the newest marketplace but still signed through src/lib/trades,
which hardcoded OffChainMarketplaceV2. On Amoy that granted V3 minter rights while
producing a V2 signature domain, so the authorized minter was not the settling
contract and a primary listing reverted at mint.

The copy existed only because dapps' getValueForTradeAsset had no USD_PEGGED_MANA
case; 29.7.1 has it, and its generateTradeValues is byte-identical for both ERC20
and USD-pegged trades, so the file is deleted as its own comment instructed.

The regression now asserts the EIP-712 domain the signer is actually handed rather
than a resolver that ought to agree with it — comparing resolvers reported this
invariant as held while it was broken. Verified it fails when the signer resolves V2.
getLatestOffchainSale repeated dapps' candidate order, so two lists had to stay in
step by hand. They agree on every chain today, but this PR already shipped one bug
from exactly that kind of drift, and the next marketplace version is when it bites.

Builder keeps its own list for a different question: every version a collection might
still hold minter rights on, including V1, which the revoke path needs and dapps has
no reason to know about.

Also drops the resolver-comparison test. With the delegation it compares a value to
itself, and a test that cannot fail reads as coverage without being any. The domain
assertion stays and still catches a divergent signer - verified.
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.

3 participants