Skip to content

Commit a3e6747

Browse files
authored
Merge pull request #9615 from decentraland/hotfix/2026-08-05
hotfix: buy collection store mints with credits
2 parents 4dd2b42 + b69d2fb commit a3e6747

23 files changed

Lines changed: 1029 additions & 319 deletions

Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ await MapRendererContainer
440440
new CreditsManagerMetaTxRelayer(dynamicWorldDependencies.CompositeWeb3Provider, staticContainer.WebRequestsContainer.WebRequestController, bootstrapContainer.DecentralandUrlsSource, creditsChainConfig),
441441
new PolygonSettlementPoller(dynamicWorldDependencies.CompositeWeb3Provider, creditsChainConfig),
442442
new ManaUsdRateReader(dynamicWorldDependencies.CompositeWeb3Provider, creditsChainConfig),
443+
creditsChainConfig,
443444
identityCache,
444445
CreditsFeatureAccess.Instance,
445446
FeaturesRegistry.Instance.IsEnabled(FeatureId.CreditsWearablePurchase) && FeaturesRegistry.Instance.IsEnabled(FeatureId.UserCredits));

Explorer/Assets/DCL/MarketplaceCredits/MarketplaceCreditsAPIService/AuthorizeCreditResponse.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ public struct AuthorizeUsdCreditBody
3333
public string tradeId;
3434
}
3535

36+
[Serializable]
37+
public struct AuthorizeUsdMintCreditBody
38+
{
39+
public int usdPriceCents;
40+
public string contractAddress;
41+
public string itemId;
42+
}
43+
3644
[Serializable]
3745
public struct ReleaseUsdIntentsBody
3846
{

Explorer/Assets/DCL/MarketplaceCredits/MarketplaceCreditsAPIService/MarketplaceCreditsAPIClient.cs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,33 @@ public virtual async UniTask<CreditPacksResponse> GetCreditPacksAsync(Cancellati
9090
.CreateFromJson<CreditPacksResponse>(WRJsonParser.Unity);
9191
}
9292

93-
public virtual async UniTask<AuthorizeCreditResponse> AuthorizeUsdCreditAsync(int usdPriceCents, string tradeId, CancellationToken ct)
93+
public virtual async UniTask<AuthorizeCreditResponse> AuthorizeUsdCreditAsync(int usdPriceCents, string tradeId, string contractAddress, string itemId, CancellationToken ct)
9494
{
9595
var url = $"{marketplaceCreditsBaseUrl}/credits/authorize";
96-
string jsonBody = JsonUtility.ToJson(new AuthorizeUsdCreditBody { usdPriceCents = usdPriceCents, tradeId = tradeId });
96+
string jsonBody = BuildAuthorizeBody(usdPriceCents, tradeId, contractAddress, itemId);
9797

9898
return await webRequestController.SignedFetchPostAsync(url, GenericPostArguments.CreateJson(jsonBody), string.Empty, ct)
9999
.CreateFromJson<AuthorizeCreditResponse>(WRJsonParser.Unity);
100100
}
101101

102+
private static string BuildAuthorizeBody(int usdPriceCents, string? tradeId, string? contractAddress, string? itemId)
103+
{
104+
bool hasItem = !string.IsNullOrEmpty(contractAddress) && !string.IsNullOrEmpty(itemId);
105+
106+
return hasItem
107+
? JsonUtility.ToJson(new AuthorizeUsdMintCreditBody
108+
{
109+
usdPriceCents = usdPriceCents,
110+
contractAddress = contractAddress,
111+
itemId = itemId,
112+
})
113+
: JsonUtility.ToJson(new AuthorizeUsdCreditBody
114+
{
115+
usdPriceCents = usdPriceCents,
116+
tradeId = tradeId ?? string.Empty,
117+
});
118+
}
119+
102120
public virtual async UniTask ReleaseUsdIntentsAsync(string[] salts, CancellationToken ct)
103121
{
104122
var url = $"{marketplaceCreditsBaseUrl}/credits/authorize/cancel";

Explorer/Assets/DCL/MarketplaceCredits/Purchase/Api/ShopListingDto.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ namespace DCL.MarketplaceCredits.Purchase
99
public class ShopListingDto
1010
{
1111
public string tradeId = null!;
12+
public string? acquisition;
1213
public string listingType = null!;
1314
public string source = null!;
1415
public string contractAddress = null!;

Explorer/Assets/DCL/MarketplaceCredits/Purchase/Chain/CreditsChainConfig.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ public class CreditsChainConfig
1313
private const string POLYGON_CREDITS_MANAGER_ADDRESS = "0x8b3a40ca1b6f5cafc99d112a4d02e897d1fd8cc5";
1414
private const string AMOY_CREDITS_MANAGER_ADDRESS = "0x8052a560e6e6ac86eeb7e711a4497f639b322fb3";
1515

16+
// CollectionStore — where a PRIMARY item with no trade is minted from (useCredits' external call target
17+
// for a mint, the way the marketplace is for a trade).
18+
private const string POLYGON_COLLECTION_STORE_ADDRESS = "0x214ffc0f0103735728dc66b61a22e4f163e275ae";
19+
private const string AMOY_COLLECTION_STORE_ADDRESS = "0xe36abc9ec616c83caaa386541380829106149d68";
20+
21+
// The offchain marketplace. Not used to buy a mint — it is the contract that exposes
22+
// `manaUsdAggregator()`, so it is the address the MANA/USD rate is read THROUGH. A trade already names
23+
// its own marketplace; a mint has none, and a collection contract does not expose that method at all.
24+
private const string POLYGON_OFFCHAIN_MARKETPLACE_ADDRESS = "0xa40b1d129b8906888720686f3a01921ddf37716f";
25+
private const string AMOY_OFFCHAIN_MARKETPLACE_ADDRESS = "0x1b67d0e31eeb6b52d8eeed71d3616c2f5b33b8e7";
26+
1627
private const string CREDITS_MANAGER_EIP712_NAME = "Decentraland Credits";
1728
private const string CREDITS_MANAGER_EIP712_VERSION = "1.0.0";
1829

@@ -22,6 +33,10 @@ public class CreditsChainConfig
2233

2334
public string CreditsManagerAddress { get; }
2435

36+
public string CollectionStoreAddress { get; }
37+
38+
public string OffChainMarketplaceAddress { get; }
39+
2540
public string CreditsManagerEip712Name => CREDITS_MANAGER_EIP712_NAME;
2641

2742
public string CreditsManagerEip712Version => CREDITS_MANAGER_EIP712_VERSION;
@@ -33,12 +48,16 @@ public CreditsChainConfig(DecentralandEnvironment environment)
3348
ChainId = POLYGON_CHAIN_ID;
3449
ReadonlyNetwork = POLYGON_READONLY_NETWORK;
3550
CreditsManagerAddress = POLYGON_CREDITS_MANAGER_ADDRESS;
51+
CollectionStoreAddress = POLYGON_COLLECTION_STORE_ADDRESS;
52+
OffChainMarketplaceAddress = POLYGON_OFFCHAIN_MARKETPLACE_ADDRESS;
3653
}
3754
else
3855
{
3956
ChainId = AMOY_CHAIN_ID;
4057
ReadonlyNetwork = AMOY_READONLY_NETWORK;
4158
CreditsManagerAddress = AMOY_CREDITS_MANAGER_ADDRESS;
59+
CollectionStoreAddress = AMOY_COLLECTION_STORE_ADDRESS;
60+
OffChainMarketplaceAddress = AMOY_OFFCHAIN_MARKETPLACE_ADDRESS;
4261
}
4362
}
4463
}

Explorer/Assets/DCL/MarketplaceCredits/Purchase/CreditsPurchaseService.cs

Lines changed: 96 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public class CreditsPurchaseService : ICreditsPurchaseService
2424
private readonly CreditsManagerMetaTxRelayer metaTxRelayer;
2525
private readonly PolygonSettlementPoller settlementPoller;
2626
private readonly ManaUsdRateReader manaUsdRateReader;
27+
private readonly CreditsChainConfig chainConfig;
2728
private readonly IWeb3IdentityCache identityCache;
2829
private readonly CreditsFeatureAccess creditsFeatureAccess;
2930
private readonly bool isFeatureEnabled;
@@ -36,6 +37,7 @@ public CreditsPurchaseService(
3637
CreditsManagerMetaTxRelayer metaTxRelayer,
3738
PolygonSettlementPoller settlementPoller,
3839
ManaUsdRateReader manaUsdRateReader,
40+
CreditsChainConfig chainConfig,
3941
IWeb3IdentityCache identityCache,
4042
CreditsFeatureAccess creditsFeatureAccess,
4143
bool isFeatureEnabled)
@@ -45,12 +47,13 @@ public CreditsPurchaseService(
4547
this.metaTxRelayer = metaTxRelayer;
4648
this.settlementPoller = settlementPoller;
4749
this.manaUsdRateReader = manaUsdRateReader;
50+
this.chainConfig = chainConfig;
4851
this.identityCache = identityCache;
4952
this.creditsFeatureAccess = creditsFeatureAccess;
5053
this.isFeatureEnabled = isFeatureEnabled;
5154
}
5255

53-
public async UniTask<CreditsQuoteResult> QuoteAsync(string tradeId, CancellationToken ct)
56+
public async UniTask<CreditsQuoteResult> QuoteAsync(ShopListingDto listing, CancellationToken ct)
5457
{
5558
if (!isFeatureEnabled || !creditsFeatureAccess.IsUserAllowed())
5659
return new CreditsQuoteResult(CreditsPurchaseError.FeatureDisabled);
@@ -62,7 +65,12 @@ public async UniTask<CreditsQuoteResult> QuoteAsync(string tradeId, Cancellation
6265

6366
SetState(CreditsPurchaseState.ResolvingListing);
6467

65-
try { return await QuoteInternalAsync(tradeId, identity.Address, ct); }
68+
try
69+
{
70+
return IsStoreMint(listing)
71+
? await QuoteStoreMintInternalAsync(listing, ct)
72+
: await QuoteInternalAsync(listing.tradeId, identity.Address, ct);
73+
}
6674
catch (OperationCanceledException)
6775
{
6876
return new CreditsQuoteResult(CreditsPurchaseError.Cancelled);
@@ -96,6 +104,42 @@ public async UniTask<CreditsPurchaseResult> PurchaseAsync(CreditsPurchaseQuote q
96104
}
97105
}
98106

107+
private static bool IsStoreMint(ShopListingDto listing) =>
108+
string.Equals(listing.acquisition, "store", StringComparison.OrdinalIgnoreCase);
109+
110+
private async UniTask<CreditsQuoteResult> QuoteStoreMintInternalAsync(ShopListingDto listing, CancellationToken ct)
111+
{
112+
if (string.IsNullOrEmpty(listing.itemId))
113+
return new CreditsQuoteResult(CreditsPurchaseError.ListingNotAvailable, "Mint listing has no itemId");
114+
115+
if (listing.available <= 0)
116+
return new CreditsQuoteResult(CreditsPurchaseError.ListingNotAvailable, "Mint is sold out");
117+
118+
if (string.IsNullOrEmpty(listing.manaWei))
119+
return new CreditsQuoteResult(CreditsPurchaseError.ListingNotAvailable, "Mint listing has no price");
120+
121+
ManaUsdRate rate;
122+
123+
try { rate = await manaUsdRateReader.ReadAsync(chainConfig.OffChainMarketplaceAddress, ct); }
124+
catch (OperationCanceledException) { throw; }
125+
catch (Exception e)
126+
{
127+
ReportHub.LogWarning(ReportCategory.CREDITS_PURCHASE,
128+
$"MANA/USD rate unavailable for mint {listing.contractAddress}-{listing.itemId}: {e.Message}");
129+
130+
return new CreditsQuoteResult(CreditsPurchaseError.PriceUnavailable, e.Message);
131+
}
132+
133+
int usdCents = CreditsTradeEncoder.RoundUpToWholeCredit(CreditsTradeEncoder.ManaWeiToUsdCents(listing.manaWei!, rate), CENTS_PER_CREDIT);
134+
BigInteger requiredManaWei = CreditsTradeEncoder.AmountOrZero(listing.manaWei!);
135+
136+
if (usdCents <= 0 || requiredManaWei <= BigInteger.Zero)
137+
return new CreditsQuoteResult(CreditsPurchaseError.ListingNotAvailable, "Mint has no price");
138+
139+
var target = new StoreMintTarget(listing.contractAddress, listing.itemId!, listing.manaWei!);
140+
return CreditsQuoteResult.Ok(CreditsPurchaseQuote.ForMint(target, usdCents, usdCents / CENTS_PER_CREDIT, requiredManaWei));
141+
}
142+
99143
private async UniTask<CreditsQuoteResult> QuoteInternalAsync(string tradeId, string buyer, CancellationToken ct)
100144
{
101145
TradeDto? trade;
@@ -161,28 +205,54 @@ private async UniTask<CreditsQuoteResult> QuoteInternalAsync(string tradeId, str
161205
if (usdCents <= 0)
162206
return new CreditsQuoteResult(CreditsPurchaseError.ListingNotAvailable, "Trade has no price");
163207

164-
return CreditsQuoteResult.Ok(new CreditsPurchaseQuote(trade, usdCents, usdCents / CENTS_PER_CREDIT, requiredManaWei, isLegacyMana));
208+
return CreditsQuoteResult.Ok(CreditsPurchaseQuote.ForTrade(trade, usdCents, usdCents / CENTS_PER_CREDIT, requiredManaWei, isLegacyMana));
165209
}
166210

167211
private async UniTask<CreditsPurchaseResult> PurchaseInternalAsync(CreditsPurchaseQuote quote, string buyer, CancellationToken ct)
168212
{
169213
BigInteger requiredManaWei = quote.RequiredManaWei;
214+
StoreMintTarget mint = quote.Mint;
170215

171-
if (!quote.IsLiveRatePrice)
216+
if (quote.Kind == CreditsListingKind.StoreMint)
217+
{
218+
SetState(CreditsPurchaseState.ResolvingListing);
219+
220+
ShopListingDto? fresh;
221+
222+
try { fresh = await shopAPIClient.GetShopListingForItemAsync(mint.CollectionAddress, mint.ItemId, ct); }
223+
catch (OperationCanceledException) { throw; }
224+
catch (Exception e)
225+
{
226+
ReportHub.LogWarning(ReportCategory.CREDITS_PURCHASE,
227+
$"Mint {mint.CollectionAddress}-{mint.ItemId} could not be re-read before buying: {e.Message}");
228+
229+
return Fail(CreditsPurchaseError.ListingNotAvailable, message: e.Message);
230+
}
231+
232+
if (fresh == null || !IsStoreMint(fresh) || fresh.available <= 0 || string.IsNullOrEmpty(fresh.manaWei))
233+
return Fail(CreditsPurchaseError.ListingNotAvailable, message: "The mint is no longer available");
234+
235+
mint = new StoreMintTarget(mint.CollectionAddress, mint.ItemId, fresh.manaWei!);
236+
requiredManaWei = CreditsTradeEncoder.AmountOrZero(fresh.manaWei!);
237+
238+
if (requiredManaWei > quote.RequiredManaWei)
239+
return Fail(CreditsPurchaseError.PriceChanged, message: "The mint price changed");
240+
}
241+
else if (!quote.IsLiveRatePrice)
172242
{
173243
SetState(CreditsPurchaseState.ResolvingListing);
174244

175245
ManaUsdRate rate;
176246

177-
try { rate = await manaUsdRateReader.ReadAsync(quote.Trade.contract, ct); }
247+
try { rate = await manaUsdRateReader.ReadAsync(quote.Trade!.contract, ct); }
178248
catch (OperationCanceledException) { throw; }
179249
catch (Exception e)
180250
{
181-
ReportHub.LogWarning(ReportCategory.CREDITS_PURCHASE, $"MANA/USD rate unavailable at purchase for trade {quote.Trade.id}: {e.Message}");
251+
ReportHub.LogWarning(ReportCategory.CREDITS_PURCHASE, $"MANA/USD rate unavailable at purchase for trade {quote.Trade!.id}: {e.Message}");
182252
return Fail(CreditsPurchaseError.PriceUnavailable, message: e.Message);
183253
}
184254

185-
requiredManaWei = CreditsTradeEncoder.UsdWeiToManaWei(quote.Trade.received[0].amount, rate);
255+
requiredManaWei = CreditsTradeEncoder.UsdWeiToManaWei(quote.Trade!.received[0].amount, rate);
186256

187257
if (requiredManaWei <= BigInteger.Zero)
188258
return Fail(CreditsPurchaseError.PriceUnavailable, message: "Trade draws no MANA");
@@ -192,7 +262,7 @@ private async UniTask<CreditsPurchaseResult> PurchaseInternalAsync(CreditsPurcha
192262

193263
AuthorizeCreditResponse authorization;
194264

195-
try { authorization = await creditsAPIClient.AuthorizeUsdCreditAsync(quote.UsdCents, quote.Trade.id, ct); }
265+
try { authorization = await creditsAPIClient.AuthorizeUsdCreditAsync(quote.UsdCents, quote.TradeId, quote.ContractAddress, quote.ItemId, ct); }
196266
catch (OperationCanceledException) { throw; }
197267
catch (UnityWebRequestException e)
198268
{
@@ -221,10 +291,16 @@ private async UniTask<CreditsPurchaseResult> PurchaseInternalAsync(CreditsPurcha
221291
authorizedCap = BigInteger.Parse(authorization.maxCreditedValue)
222292
+ CreditsTradeEncoder.UncreditedValue(authorization.maxCreditedValue, authorization.credit.availableAmount);
223293

224-
useCreditsCalldata = CreditsTradeEncoder.BuildUseCreditsCalldata(
225-
quote.Trade, buyer, authorization.credit, authorization.maxCreditedValue,
226-
DateTimeOffset.UtcNow.ToUnixTimeSeconds() + EXTERNAL_CALL_TTL_SECONDS,
227-
RandomSalt());
294+
long externalCallExpiresAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + EXTERNAL_CALL_TTL_SECONDS;
295+
296+
useCreditsCalldata = quote.Kind == CreditsListingKind.StoreMint
297+
? CreditsTradeEncoder.BuildStoreMintUseCreditsCalldata(
298+
chainConfig.CollectionStoreAddress, mint.CollectionAddress, mint.ItemId, mint.PriceWei,
299+
buyer, authorization.credit, authorization.maxCreditedValue,
300+
externalCallExpiresAt, RandomSalt())
301+
: CreditsTradeEncoder.BuildUseCreditsCalldata(
302+
quote.Trade!, buyer, authorization.credit, authorization.maxCreditedValue,
303+
externalCallExpiresAt, RandomSalt());
228304
}
229305
catch (Exception e)
230306
{
@@ -236,10 +312,10 @@ private async UniTask<CreditsPurchaseResult> PurchaseInternalAsync(CreditsPurcha
236312
if (authorizedCap < requiredManaWei)
237313
{
238314
ReportHub.LogWarning(ReportCategory.CREDITS_PURCHASE,
239-
$"Authorized cap {authorizedCap} wei cannot cover the {requiredManaWei} wei trade {quote.Trade.id} draws");
315+
$"Authorized cap {authorizedCap} wei cannot cover the {requiredManaWei} wei {QuoteLabel(quote)} draws");
240316

241317
await ReleaseIntentAsync(authorization.credit.id);
242-
return Fail(CreditsPurchaseError.PriceChanged, message: "The authorized credit cannot cover this trade");
318+
return Fail(CreditsPurchaseError.PriceChanged, message: "The authorized credit cannot cover this purchase");
243319
}
244320

245321
SetState(CreditsPurchaseState.Signing);
@@ -269,7 +345,7 @@ private async UniTask<CreditsPurchaseResult> PurchaseInternalAsync(CreditsPurcha
269345
SetState(CreditsPurchaseState.Failed);
270346
return new CreditsPurchaseResult(CreditsPurchaseError.SettlementPending, message: relay.Message);
271347
case RelayOutcome.RelayerRejected:
272-
ReportHub.LogWarning(ReportCategory.CREDITS_PURCHASE, $"Relayer refused trade {quote.Trade.id}: {relay.Message}");
348+
ReportHub.LogWarning(ReportCategory.CREDITS_PURCHASE, $"Relayer refused {QuoteLabel(quote)}: {relay.Message}");
273349
await ReleaseIntentAsync(authorization.credit.id);
274350
return Fail(CreditsPurchaseError.RelayerUnavailable, message: relay.Message);
275351
}
@@ -298,6 +374,11 @@ private async UniTask<CreditsPurchaseResult> PurchaseInternalAsync(CreditsPurcha
298374
}
299375
}
300376

377+
private static string QuoteLabel(in CreditsPurchaseQuote quote) =>
378+
quote.Kind == CreditsListingKind.StoreMint
379+
? $"mint {quote.Mint.CollectionAddress}-{quote.Mint.ItemId}"
380+
: $"trade {quote.Trade!.id}";
381+
301382
private async UniTask ReleaseIntentAsync(string creditId)
302383
{
303384
using var timeoutCts = new CancellationTokenSource(RELEASE_INTENT_TIMEOUT);

0 commit comments

Comments
 (0)