From 909bcebb389d634a0933a45e0974e288c76543e9 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 7 Oct 2026 15:03:53 -0400 Subject: [PATCH] [econ] bypass purchase offers for contributors --- apps/econ/src/econ.app.ts | 19 +++++++++++++++---- apps/econ/src/openapi.ts | 6 +++++- apps/econ/src/test/integration/api.test.ts | 18 ++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index fbe4789..f044f35 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -4500,7 +4500,9 @@ const app = new Hono({ strict: false }) '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', - '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', 'use. `Value` is BOTH balances the purchase moved, each a RESULTING total:', @@ -4547,15 +4549,24 @@ const app = new Hono({ strict: false }) if (int(body.RequestedAmount) !== offer.CurrencyAmount) { 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) 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 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. if (charged && !(await spendCurrency(c.env.DB, id, tokens, offer.Price, startingTokens))) { return refuse('Not enough tokens') diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index dd9d764..628caf6 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -1142,7 +1142,11 @@ export const AwardRoomCurrencyResultList = z.array(AwardRoomCurrencyResult) export const PurchaseRoomCurrencyRequest = z.object({ PurchaseOfferId: z.string().describe('The offer — a `CurrencyPurchaseOfferId`'), 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' + ), }) /** diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 4b9b985..b90e4d6 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -2302,6 +2302,24 @@ describe('econ endpoints', () => { expect([await tokens(1), await tokens(7402)]).toEqual([1555, 100]) 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)') .bind(sold.CurrencyId, other.CurrencyId) .run()