How to stop cross-server markets duplicating items
Reading a memory store listing and then removing it lets two servers sell it twice. How to claim entries safely with UpdateAsync.
What it looks like
A cross-server market, auction, shared stock or party list lives in a MemoryStoreHashMap or MemoryStoreSortedMap. The server reads an entry with GetAsync, decides, then writes with SetAsync or removes with RemoveAsync. Another server can do the same thing between the read and the write.
Luau
local listing = market:GetAsync(id) if listing then give(buyer, listing.item) market:RemoveAsync(id) end
How to detect it
- Find every
MemoryStoreService:GetHashMapandGetSortedMapthat more than one server writes to. Market listings, auctions, limited stock, shared inventories and global counters are the usual ones. - Look for
GetAsyncfollowed bySetAsyncorRemoveAsyncon the same key, with something given, charged or decided in between. - Roblox's memory store best practices say to prefer
UpdateAsyncoverSetAsync. Its reference saysUpdateAsyncsaves only if nobody changed the key since it was read, and calls the transform function again with the newer value otherwise. Returningnilcancels the update. - Check that the claim happens inside
UpdateAsync, and that items or currency are only given after it succeeds and returns the claimed value. - The transform function can run more than once, so it must not give anything itself. See Work done inside an UpdateAsync callback.
RemoveAsyncdoesn't say whether the key existed, so removing an entry doesn't prove this server was the one that got it.
What an exploiter could do
Two accounts in two servers buy the same listing at the same moment. Both servers read it, both hand over the item, and the seller's item is duplicated. With an auction, two bids can both be accepted as the highest, or a bid can overwrite a higher one. Coordinated players can repeat it as often as they like.
How to fix it
- Claim entries with
UpdateAsync. Inside the transform function, returnnilif the entry is gone or already claimed, otherwise return it marked as claimed by this server. - Give items only after
UpdateAsyncreturns the claimed entry. Take the payment before claiming and refund it if the claim fails, so a player can't spend the same coins twice while the claim is in flight. - Keep a record of who bought what in a data store, since memory store entries expire.
Vulnerable example
Vulnerable
local MemoryStoreService = game:GetService("MemoryStoreService") local ReplicatedStorage = game:GetService("ReplicatedStorage") local PlayerData = require(script.Parent.PlayerData) local market = MemoryStoreService:GetHashMap("Market") ReplicatedStorage.Remotes.BuyListing.OnServerEvent:Connect(function(player: Player, id: unknown) local profile = PlayerData.get(player) if typeof(id) ~= "string" or not profile then return end local ok, listing = pcall(market.GetAsync, market, id) if not ok or typeof(listing) ~= "table" or profile.Coins < listing.price then return end profile.Coins -= listing.price table.insert(profile.Inventory, listing.item) pcall(market.RemoveAsync, market, id) end)
Fixed example
Fixed
local MemoryStoreService = game:GetService("MemoryStoreService") local ReplicatedStorage = game:GetService("ReplicatedStorage") local PlayerData = require(script.Parent.PlayerData) local market = MemoryStoreService:GetHashMap("Market") ReplicatedStorage.Remotes.BuyListing.OnServerEvent:Connect(function(player: Player, id: unknown) local profile = PlayerData.get(player) if typeof(id) ~= "string" or not profile then return end local found, listing = pcall(market.GetAsync, market, id) if not found or typeof(listing) ~= "table" or profile.Coins < listing.price then return end local price = listing.price profile.Coins -= price local ok, claimed = pcall(market.UpdateAsync, market, id, function(current: any) if typeof(current) ~= "table" or current.buyer or current.price ~= price then return nil end current.buyer = player.UserId return current end, 300) if ok and typeof(claimed) == "table" and claimed.buyer == player.UserId then table.insert(profile.Inventory, claimed.item) else profile.Coins += price end end)
Common false positives
- Memory stores used only by one server, or only for display, such as a "players online" count, aren't worth reporting.
- Queues are covered by Memory store queue items that can pay out twice.
- Changes made with
UpdateAsyncthat hand anything out only after it returns, like the fixed example, are the fix.