Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,10 @@ GENERATION_TRY_COST_VOTES=100
STARS_TOPUP_VOTES_PER_STAR=10
# $CUBE credited to the referrer when an invited user's pass is minted. 0 = off.
REFERRAL_MINT_REWARD_VOTES=200
# Community chats: JSON array of chat ids, e.g. [-1001234567890,-1009876543210]. Empty = off.
COMMUNITY_CHAT_IDS=[]
TIP_VOTES=50
TIP_BASE_PER_DAY=3
TIP_REP_PER_EXTRA=100
TIP_EMOJI=🧊
INVITE_LOGIN_REWARD_VOTES=20
12 changes: 8 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ Every place is one game-theory engine reading the pass's 120 on-chain traits (`U
- **Referrals** — `/start` param → `referId` on `/api/auth/login` stores `referalId`; the referrer is credited `REFERRAL_MINT_REWARD_VOTES` (default 200) **when the invitee's pass is minted** — human-gated ⇒ unfarmable. Wired inside the approve path (`rewardReferrer` dep, non-fatal, at-most-once via the mint CAS).
- **TON donations** — watcher (`subscription-core.ts`/`subscription.ts`) credits votes for TON sent **from the bound wallet** (`findUserByAddress` → `addPoints(..., Donation)`). Donation target address = `COLLECTION_OWNER`, exposed via `GET /api/public/config`.
- **Telegram Stars top-up** — `POST /api/users/topup/invoice` (pure `topup-invoice-handler.ts` + composer `topup-invoice.ts`, `createInvoiceLink` currency `XTR`); bot `topup` feature handles `pre_checkout_query` + `successful_payment`; payload `cube-topup:<userId>:<stars>:<votes>` round-trips through Telegram (tamper-proof, honors the quoted rate `STARS_TOPUP_VOTES_PER_STAR`, default 10/⭐). Idempotent on `telegram_payment_charge_id` via unique `StarsPurchase.chargeId` — record-then-credit, replay is a no-op.
- **Community tips** — pass holders react `TIP_EMOJI` (default 🧊) or reply `/tip` in `COMMUNITY_CHAT_IDS`; the author gets `TIP_VOTES` (50) as `BalanceChangeType.Tip`. Allowance per holder per UTC day = `TIP_BASE_PER_DAY` (3) + floor((rep.helped+rep.gave)/`TIP_REP_PER_EXTRA` (100)); computed from the `Tip` ledger (unique on chat+message+tipper ⇒ re-reactions are no-ops, record-then-credit). Reactions need the bot as chat admin, a `ChatMessage` index (7-day TTL) supplies the author. Strangers get a `User` shell (`joinedViaChat`) and one chat nudge (`communityNudgedAt`). `src/bot/features/community/*`, pure `give-tip.ts`.
- **Chat invites** — `POST /api/users/community` mints a personal `createChatInviteLink` per chat (name = user id, cached in `User.inviteLinks`); `chat_member` join sets `referalId` + `joinedViaChat` (first link wins, wallet-bound users untouched). `/api/auth/login` CAS-stamps `firstLoginAt`; on the first login of a `joinedViaChat` user the inviter gets `INVITE_LOGIN_REWARD_VOTES` (20) as `Invite`. Boot migration `ensureFirstLoginMigration` back-fills `firstLoginAt` for pre-existing users.

`BalanceChangeType`: Initial, Deposit, Withdraw, Dice/Task/Trade (legacy), Referral, Donation, Claim, **Generation** (sink), **StarsTopup** (faucet).
`BalanceChangeType`: Initial, Deposit, Withdraw, Dice/Task/Trade (legacy), Referral, Donation, Claim, **Generation** (sink), **StarsTopup** (faucet), **Stake**/**Payout** (Bali), **Tip**, **Invite**.

## Commands
```bash
Expand Down Expand Up @@ -75,9 +77,9 @@ All authenticated endpoints validate Telegram's `initData` (HMAC + 24h expiry)
4. `set-wallet` answers **409 `wallet_taken`** when the address belongs to another account; the frontend `post()` helper returns JSON envelopes for non-2xx so `code` reaches the UI.

## Bot
Middleware order: `autoRetry → updateLogger (dev) → autoChatAction → hydrate → session → slapReaction → i18n → attachUser → queueMenu → [features]`.
Middleware order: `autoRetry → updateLogger (dev) → autoChatAction → hydrate → session → slapReaction → i18n → community (group chats, swallows) → attachUser → queueMenu → [features]`.

Features (`src/bot/index.ts`): start, help, queue (admin: `/queue` browser + Approve/Decline callbacks), parameters (admin), collection (admin), stats, whales, line, transaction (admin), user (admin), resolve (admin: `/resolve` forces the current Bali window on staging/dev), **topup** (Stars `pre_checkout_query` + `successful_payment`), then `removedCommandsFeature` (points `/dice`, `/mint`, `/play`… to the Mini App) and `unhandledFeature` last.
Features (`src/bot/index.ts`): start, help, queue (admin: `/queue` browser + Approve/Decline callbacks), parameters (admin), collection (admin), stats, whales, line, transaction (admin), user (admin), resolve (admin: `/resolve` forces the current Bali window on staging/dev), **topup** (Stars `pre_checkout_query` + `successful_payment`), **community** (mounted before attachUser: message indexer, 🧊 reaction / `/tip` tips, invite-link joins — only in `COMMUNITY_CHAT_IDS`), then `removedCommandsFeature` (points `/dice`, `/mint`, `/play`… to the Mini App) and `unhandledFeature` last.

## Security
- Leaderboard pagination: limit 1–100, skip ≥ 0.
Expand All @@ -86,12 +88,14 @@ Features (`src/bot/index.ts`): start, help, queue (admin: `/queue` browser + App
- Rate limits per route in `server.ts` (`/api/mint/generate` 6/min — it's a paid Stability call).
- Rate limits: `/api/pass/scan` and `/api/pass/select` 10/min (toncenter call).
- Rate limits: `/api/world/*` — `state` 60/min, `visit` 10/min, `history` 30/min, `pass/:index` (public) 60/min.
- Rate limits: `/api/users/community` 30/min (Telegram getChat/createChatInviteLink).

## Deploy notes
- **Prod deploys are fenced**: `.kamal/hooks/pre-deploy` aborts any non-staging deploy unless `PROD_CUTOVER=yes` is in the environment (`PROD_CUTOVER=yes kamal deploy`). Never set it on the user's behalf — the cutover is their call. `-d staging` passes untouched.
- Production still runs **v1**; this tree replaces it wholesale on cutover. Before deploy: `CHECK_MONGO_URI=<prod> npx tsx scripts/check-prod-users.ts` — read-only; blocks on duplicate wallets/ids (v3 unique indexes), non-BigInt-castable votes, unknown states; warns on v1 `WaitWallet`/`WaitDescription` (reset to `WaitNothing` at boot by `ensureLegacyStateMigration`), stuck mint claims, missing names, old-host `data/` paths.
- `STAGING=true` boots API-only (no tx loop, no Telegram engagement).
- Stale env keys from v2 (`XROCKET_*`, `ADSGRAM_*`, `SEASON_PASS_*`, `MINT_FLOOR_*`, …) are ignored by the config schema; new optional keys: `GENERATION_TRY_COST_VOTES`, `STARS_TOPUP_VOTES_PER_STAR`, `REFERRAL_MINT_REWARD_VOTES`.
- Stale env keys from v2 (`XROCKET_*`, `ADSGRAM_*`, `SEASON_PASS_*`, `MINT_FLOOR_*`, …) are ignored by the config schema; new optional keys: `GENERATION_TRY_COST_VOTES`, `STARS_TOPUP_VOTES_PER_STAR`, `REFERRAL_MINT_REWARD_VOTES`, `TIP_EMOJI`, `TIP_VOTES`, `TIP_BASE_PER_DAY`, `TIP_REP_PER_EXTRA`, `COMMUNITY_CHAT_IDS`, `INVITE_LOGIN_REWARD_VOTES`.
- **Community chats**: the bot must be admin in every `COMMUNITY_CHAT_IDS` chat with the "invite users" right, and `BOT_ALLOWED_UPDATES` is a **replacement allowlist, not an addition** — set it to the full array `["message","edited_message","callback_query","pre_checkout_query","my_chat_member","chat_member","message_reaction"]` (dropping any of the non-community entries silently breaks admin mint-approval buttons, Stars top-up, or bot join tracking). Deploy dark (`COMMUNITY_CHAT_IDS=[]`), then set the ids. Holders with anonymous reactions produce no update — `/tip` is the fallback.
- Pre-deploy: `npm run smoke:api` (all secrets overridden with fakes — it can never hit a paid API or the live bot).
- **Staging bot**: `kamal deploy -d staging` → @cubeworldsbot at https://staging.cubeworlds.club (`config/deploy.staging.yml` + `.kamal/secrets.staging`; own service name/DB `cube-worlds-bot-staging`/volume `/srv/cube_worlds_staging/data`). Shares chain keys + admins with prod, so an admin Approve there mints a real NFT.
- `/tonconnect-manifest.json` is generated from `WEB_APP_URL` (ton_proof domain check), not a static landing file.
8 changes: 8 additions & 0 deletions locales/en.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,11 @@ queue =
.new_nft_dice = {$emoji1} Dice victory! {$emoji2}
Congratulations to the winner on receiving a new NFT <strong>#{$number}</strong> in {$collectionLink}!
.new_nft_button = Open NFT

tip_command =
.description = 🧊 Reply to a message to tip its author $CUBE
community_nudge = 🧊 @{$name} got {$votes} $CUBE from a holder. Open @cube_worlds_bot to claim it.
tip_invalid_target = Reply to a member's message with /tip to tip them.
tip_holders_only = Only Cube Worlds pass holders can tip.
tip_no_allowance = You have used all your tips for today. Play in Bali to earn more.
tip_failed = Tip failed, please try again later.
8 changes: 8 additions & 0 deletions locales/ru.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,11 @@ queue =
.new_nft_dice = {$emoji1} Победа в кубиках! {$emoji2}
Поздравляем победителя с получением нового NFT <strong>№{$number}</strong> в {$collectionLink}!
.new_nft_button = Открыть NFT

tip_command =
.description = 🧊 Ответь на сообщение, чтобы дать автору $CUBE
community_nudge = 🧊 @{$name} получил {$votes} $CUBE от холдера. Открой @cube_worlds_bot, чтобы забрать.
tip_invalid_target = Ответь командой /tip на сообщение участника, чтобы дать ему $CUBE.
tip_holders_only = Давать $CUBE могут только держатели пропуска Cube Worlds.
tip_no_allowance = На сегодня твои типы закончились. Играй на Бали, чтобы получить больше.
tip_failed = Не получилось, попробуй позже.
7 changes: 7 additions & 0 deletions scripts/smoke-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,13 @@ async function run() {
expect(body.error !== 'API route not found', `route not registered: ${body.error}`)
})

await step('POST /api/users/community answers with the envelope (no chats configured)', async () => {
const body = await post<Record<string, unknown>>('/api/users/community', { initData })
expect(body.tips === null, `tips should be null for a non-holder, got ${JSON.stringify(body.tips)}`)
expect(Array.isArray(body.invites) && (body.invites as unknown[]).length === 0, 'invites should be empty with COMMUNITY_CHAT_IDS unset')
expect(body.invitedLoggedIn === 0, 'invitedLoggedIn should be 0')
})

await step('POST /api/pass/scan without a bound wallet returns 400 wallet_required', async () => {
const response = await fetch(`${BASE}/api/pass/scan`, {
method: 'POST',
Expand Down
84 changes: 84 additions & 0 deletions src/backend/auth-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ interface StubUser {
name?: string
wallet?: string
referalId?: number
joinedViaChat?: boolean
firstLoginAt?: Date
votes: bigint
minted: boolean
state: string
Expand All @@ -33,6 +35,8 @@ interface AuthTestContext {
verifyCalls: Array<{ passAddress: string, ownerAddress: string }>
setPassCalls: Array<{ userId: number, index: number, verifiedAt: Date }>
clearPassCalls: number[]
firstLogins: number[]
inviterCredits: number[]
}

function toResolvedUser(user: StubUser): ResolvedUser {
Expand Down Expand Up @@ -68,6 +72,8 @@ async function createAuthTestContext(
const verifyCalls: Array<{ passAddress: string, ownerAddress: string }> = []
const setPassCalls: Array<{ userId: number, index: number, verifiedAt: Date }> = []
const clearPassCalls: number[] = []
const firstLogins: number[] = []
const inviterCredits: number[] = []

const dependencies: AuthHandlerDependencies = {
validateInitData: () => {},
Expand Down Expand Up @@ -104,6 +110,14 @@ async function createAuthTestContext(
clearUserPass: async (userId) => {
clearPassCalls.push(userId)
},
setFirstLoginAt: async (userId: number) => {
const user = users.get(userId)
if (!user || user.firstLoginAt) return false
user.firstLoginAt = new Date()
firstLogins.push(userId)
return true
},
creditInviter: async (inviterId: number) => { inviterCredits.push(inviterId) },
...overrides,
}

Expand All @@ -119,6 +133,8 @@ async function createAuthTestContext(
verifyCalls,
setPassCalls,
clearPassCalls,
firstLogins,
inviterCredits,
}
}

Expand Down Expand Up @@ -473,6 +489,74 @@ test('login with a stale pass that left the wallet clears it', async (t) => {
assert.deepEqual(ctx.clearPassCalls, [1001])
})

test('first login stamps firstLoginAt and pays the inviter for chat-invited users', async (t) => {
const ctx = await createAuthTestContext()
t.after(() => ctx.app.close())
ctx.users.set(1001, createStubUser({ joinedViaChat: true, referalId: 2002 }))
await ctx.app.inject({ method: 'POST', url: '/api/auth/login', payload: { initData: 'x' } })
assert.deepEqual(ctx.firstLogins, [1001])
assert.deepEqual(ctx.inviterCredits, [2002])
})

test('a second login pays nothing', async (t) => {
const ctx = await createAuthTestContext()
t.after(() => ctx.app.close())
ctx.users.set(1001, createStubUser({ joinedViaChat: true, referalId: 2002, firstLoginAt: new Date() }))
await ctx.app.inject({ method: 'POST', url: '/api/auth/login', payload: { initData: 'x' } })
assert.deepEqual(ctx.firstLogins, [])
assert.deepEqual(ctx.inviterCredits, [])
})

test('app deep-link referrals stamp firstLoginAt but pay no login drop', async (t) => {
const ctx = await createAuthTestContext()
t.after(() => ctx.app.close())
ctx.users.set(1001, createStubUser({ referalId: 2002 }))
await ctx.app.inject({ method: 'POST', url: '/api/auth/login', payload: { initData: 'x' } })
assert.deepEqual(ctx.firstLogins, [1001])
assert.deepEqual(ctx.inviterCredits, [])
})

test('a tipped stranger who later opens an app deep-link does not pay the deep-link referrer a chat-invite drop', async (t) => {
// markJoinedViaChat (give-tip.ts) sets joinedViaChat without ever touching
// referalId — unlike setChatReferral (a real invite-link join), which sets
// both together. So this user has joinedViaChat=true but no referalId yet.
const taggedStranger = createStubUser({ joinedViaChat: true, referalId: undefined, wallet: undefined })
const deepLinkReferrer = createStubUser({ id: 9009 })
const users = new Map<number, StubUser>([
[1001, taggedStranger],
[9009, deepLinkReferrer],
])
const lookup = async (id: number) => {
const user = users.get(id)
return user ? toResolvedUser(user) : null
}
const ctx = await createAuthTestContext({ findUserById: lookup, findOrCreateUser: lookup })
t.after(() => ctx.app.close())

const response = await ctx.app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { initData: 'x', referId: '9009' },
})

// The unrelated deep-link referral still gets recorded normally...
assert.equal(response.json().referalId, 9009)
assert.equal(taggedStranger.referalId, 9009)
// ...but the chat-invite login drop must never go to it: nobody actually
// sent this user a chat invite link.
assert.deepEqual(ctx.firstLogins, [1001])
assert.deepEqual(ctx.inviterCredits, [])
})

test('inviter credit failure is logged and does not fail the login', async (t) => {
const ctx = await createAuthTestContext({ creditInviter: async () => { throw new Error('mongo down') } })
t.after(() => ctx.app.close())
ctx.users.set(1001, createStubUser({ joinedViaChat: true, referalId: 2002 }))
const response = await ctx.app.inject({ method: 'POST', url: '/api/auth/login', payload: { initData: 'x' } })
assert.equal(response.statusCode, 200)
assert.ok(ctx.errorLogs.some(m => m.includes('Invite credit failed')))
})

test('login keeps a stale pass when the provider fails', async (t) => {
const ctx = await createAuthTestContext({
verifyPassOwnership: async () => { throw new Error('toncenter down') },
Expand Down
31 changes: 30 additions & 1 deletion src/backend/auth-handler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { InitData } from '@telegram-apps/init-data-node'
import type { FastifyInstance } from 'fastify'
import type { Pass } from './login-payload'
import { clearUserPass, findOrCreateUser, findUserById, setUserPass } from '#root/common/models/User'
import { BalanceChangeType } from '#root/common/models/Balance'
import { addPoints, clearUserPass, findOrCreateUser, findUserById, setFirstLoginAt, setUserPass } from '#root/common/models/User'
import { config } from '#root/config'
import { logger } from '#root/logger'
import { defaultParseInitData, defaultValidateInitData } from './init-data'
import { loginPayload } from './login-payload'
Expand Down Expand Up @@ -30,6 +32,10 @@ export interface AuthHandlerDependencies {
verifyPassOwnership: (passAddress: string, ownerAddress: string) => Promise<boolean>
setUserPass: (userId: number, pass: Pass, verifiedAt: Date) => Promise<void>
clearUserPass: (userId: number) => Promise<void>
// Community: CAS stamp of the first app login (true ⇒ this call was first).
setFirstLoginAt: (userId: number, now: Date) => Promise<boolean>
// Pays INVITE_LOGIN_REWARD_VOTES to the inviter of a chat-invited user.
creditInviter: (inviterId: number) => Promise<void>
}

function createDefaultDependencies(): AuthHandlerDependencies {
Expand All @@ -44,6 +50,12 @@ function createDefaultDependencies(): AuthHandlerDependencies {
verifyPassOwnership,
setUserPass,
clearUserPass,
setFirstLoginAt,
creditInviter: async (inviterId) => {
const votes = BigInt(config.INVITE_LOGIN_REWARD_VOTES)
if (votes <= 0n) return
await addPoints(inviterId, votes, BalanceChangeType.Invite)
},
}
}

Expand Down Expand Up @@ -95,6 +107,12 @@ export function buildAuthHandler(
await user.save()
}

// Snapshot before the referrer block below can write user.referalId
// from an unrelated app deep-link — the chat-invite payout must only
// ever credit whoever sent the real invite-link join (Task 4), never
// a deep-link referrer who happens to land in the same field.
const chatInviterId = user.joinedViaChat ? user.referalId : undefined

const userAlreadyInvited = user.wallet || user.referalId
if (referId && !userAlreadyInvited) {
const receiverId = Number(referId)
Expand All @@ -108,6 +126,17 @@ export function buildAuthHandler(
}
}

// Community: first app login. The CAS makes a double login pay once;
// only chat-invited shells earn their inviter the login drop.
const firstLogin = await dependencies.setFirstLoginAt(user.id, new Date())
if (firstLogin && chatInviterId) {
try {
await dependencies.creditInviter(chatInviterId)
} catch (err) {
dependencies.error(`Invite credit failed for inviter ${chatInviterId} of ${user.id}: ${(err as Error).message}`)
}
}

// Hourly ownership revalidation. Provider failure keeps the pass —
// never lock a holder out on a toncenter outage.
const pass = user.pass
Expand Down
Loading