[econ] bypass purchase offers for contributors

This commit is contained in:
Devin Zuczek
2026-10-07 15:03:53 -04:00
parent f71d55c4d3
commit 909bcebb38
3 changed files with 38 additions and 5 deletions
+15 -4
View File
@@ -4500,7 +4500,9 @@ const app = new Hono<App>({ strict: false })
'drew it is refused rather than sold at numbers the player did not see.', 'drew it is refused rather than sold at numbers the player did not see.',
'', '',
'The tokens are paid to the room’s OWNER (its `CreatorAccountId`), as a room-key sale', 'The tokens are paid to the room’s OWNER (its `CreatorAccountId`), as a room-key sale',
'is; an owner buying from their own shop, and a free offer, move no tokens.', 'is. The room’s owner and co-owners (the `canManageRoom` set) take from their own shop',
'for free: the client shows them the offer at no cost and posts `RequestedPrice=0`, so',
'their posted price is not checked and no tokens move. A free offer moves none either.',
'', '',
'Answers the `{ Value, Success, Error, error_id }` envelope the room-currency writes', 'Answers the `{ Value, Success, Error, error_id }` envelope the room-currency writes',
'use. `Value` is BOTH balances the purchase moved, each a RESULTING total:', 'use. `Value` is BOTH balances the purchase moved, each a RESULTING total:',
@@ -4547,15 +4549,24 @@ const app = new Hono<App>({ strict: false })
if (int(body.RequestedAmount) !== offer.CurrencyAmount) { if (int(body.RequestedAmount) !== offer.CurrencyAmount) {
return refuse('Requested amount does not match') return refuse('Requested amount does not match')
} }
if (int(body.RequestedPrice) !== offer.Price) return refuse('Requested price does not match')
const ownerId = await getRoomOwnerId(c.env.DB, roomId) const ownerId = await getRoomOwnerId(c.env.DB, roomId)
if (ownerId === null) return refuse('Purchase offer is not available') if (ownerId === null) return refuse('Purchase offer is not available')
// The owner paying themselves would be a debit and a credit of the same tokens. // The room's owner and co-owners take from their own shop for free: the client
// shows THEM the offer at no cost and posts `RequestedPrice=0`, so holding a
// contributor to the offer's price refused every purchase they made. Nobody is
// charged — the owner paying themselves would be a debit and a credit of the same
// tokens, and a co-owner is building the shop, not buying from it — so the posted
// price is not checked for them at all; everyone else must have seen the real one.
const canManage = (await canManageRoomById(c.env.DB, roomId, id)) === true
if (!canManage && int(body.RequestedPrice) !== offer.Price) {
return refuse('Requested price does not match')
}
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
const tokens = CurrencyType.RecCenterTokens const tokens = CurrencyType.RecCenterTokens
const charged = offer.Price > 0 && ownerId !== id const charged = offer.Price > 0 && !canManage
// Charged FIRST, and atomically: a buyer who cannot pay is handed nothing. // Charged FIRST, and atomically: a buyer who cannot pay is handed nothing.
if (charged && !(await spendCurrency(c.env.DB, id, tokens, offer.Price, startingTokens))) { if (charged && !(await spendCurrency(c.env.DB, id, tokens, offer.Price, startingTokens))) {
return refuse('Not enough tokens') return refuse('Not enough tokens')
+5 -1
View File
@@ -1142,7 +1142,11 @@ export const AwardRoomCurrencyResultList = z.array(AwardRoomCurrencyResult)
export const PurchaseRoomCurrencyRequest = z.object({ export const PurchaseRoomCurrencyRequest = z.object({
PurchaseOfferId: z.string().describe('The offer — a `CurrencyPurchaseOfferId`'), PurchaseOfferId: z.string().describe('The offer — a `CurrencyPurchaseOfferId`'),
RequestedAmount: z.string().describe('How much room currency; must equal the offer’s'), RequestedAmount: z.string().describe('How much room currency; must equal the offer’s'),
RequestedPrice: z.string().describe('What it costs in tokens; must equal the offer’s'), RequestedPrice: z
.string()
.describe(
'What it costs in tokens; must equal the offer’s, except for the room’s owner and co-owners, who are shown 0 and pay nothing'
),
}) })
/** /**
@@ -2302,6 +2302,24 @@ describe('econ endpoints', () => {
expect([await tokens(1), await tokens(7402)]).toEqual([1555, 100]) expect([await tokens(1), await tokens(7402)]).toEqual([1555, 100])
expect(await drainFrames()).toEqual([]) expect(await drainFrames()).toEqual([])
// A co-owner (account 2 on room 2511) takes from the shop for free too. The client
// shows them the offer at no cost and posts `RequestedPrice=0` — which a stranger
// is refused for above — so their price is not checked: 0 and the real one both go
// through, nobody is charged, the owner is not paid, and no frame is sent.
await setTokens(2, 50)
const coOwner = (await (await purchase('2', offerId, 444, 0)).json()) as typeof gratis
expect(coOwner.Value.CurrencyBalanceResponse.Balance).toBe(444)
expect(coOwner.Value.TokenBalanceResponse.Balance).toBe(50)
const coOwnerAgain = (await (await purchase('2', offerId, 444, 555)).json()) as typeof gratis
expect(coOwnerAgain.Value.CurrencyBalanceResponse.Balance).toBe(888)
expect(coOwnerAgain.Value.TokenBalanceResponse.Balance).toBe(50)
// The amount still has to be the offer's, manager or not.
expect(await (await purchase('2', offerId, 1, 0)).json()).toEqual(
refused('Requested amount does not match')
)
expect([await tokens(1), await tokens(2)]).toEqual([1555, 50])
expect(await drainFrames()).toEqual([])
await env.DB.prepare('DELETE FROM room_balance WHERE currency_id IN (?1, ?2)') await env.DB.prepare('DELETE FROM room_balance WHERE currency_id IN (?1, ?2)')
.bind(sold.CurrencyId, other.CurrencyId) .bind(sold.CurrencyId, other.CurrencyId)
.run() .run()