Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

game_relay.gno

7.30 Kb · 203 lines
  1package game33
  2
  3// ── Gasless relay: treasury pays gas for BuyTool and TrainSkill ──────────────
  4//
  5// The treasury server calls RelayBuyTool / RelayTrainSkill from the relay
  6// address (set by SetRelayAddress). Effects are recorded against `player`,
  7// not against the relay caller.
  8//
  9// Security model:
 10//   - relayAddress is the ONLY caller that may use these functions.
 11//   - Each call requires an ed25519 signature from the same claimSignerPubKey
 12//     used for claim vouchers. The server signs with MOJI_CLAIM_SIGNER_SEED.
 13//   - Replay protection: relayNonces[player] must increase monotonically.
 14//   - Expiry: relay vouchers expire within relayMaxTTL seconds.
 15//   - The relay cannot grant tools the player cannot normally buy (same
 16//     prerequisite and balance checks as BuyTool/TrainSkill).
 17//
 18// Relay voucher message (signed by the server, verified on-chain):
 19//   gnomoji-relay-v1|<chainId>|<kind>|<player>|<extra>|<nonce>|<expiry>
 20//
 21// Where:
 22//   kind  = "BuyTool" | "TrainSkill"
 23//   extra = toolId   (BuyTool) | skillId (TrainSkill)
 24
 25import (
 26	"crypto/ed25519"
 27	"encoding/hex"
 28	"strconv"
 29	"time"
 30
 31	"chain/runtime"
 32
 33	"chain"
 34)
 35
 36const relayMaxTTL = int64(300) // 5 minutes — relay vouchers are short-lived
 37
 38// relayVoucherMessage builds the canonical message the server signs.
 39func relayVoucherMessage(kind, player, extra string, nonce, expiry int64) string {
 40	return "gnomoji-relay-v1|" + runtime.ChainID() + "|" + kind + "|" + player + "|" +
 41		extra + "|" + strconv.FormatInt(nonce, 10) + "|" + strconv.FormatInt(expiry, 10)
 42}
 43
 44// lastRelayNonce returns the last relay nonce for player (0 if none).
 45func lastRelayNonce(player string) int64 {
 46	if !relayNonces.Has(player) {
 47		return 0
 48	}
 49	n, _ := strconv.ParseInt(relayNonces.Get(player).(string), 10, 64)
 50	return n
 51}
 52
 53// useRelayVoucher verifies and consumes a relay voucher for player.
 54// Panics on any failure so the entire tx is rolled back.
 55func useRelayVoucher(kind, player, extra string, nonce, expiry int64, sigHex string) {
 56	if len(claimSignerPubKey) != 32 {
 57		panic("relay disabled: no claim signer configured")
 58	}
 59	now := time.Now().Unix()
 60	if expiry < now {
 61		panic("relay voucher expired")
 62	}
 63	if expiry > now+relayMaxTTL {
 64		panic("relay voucher expiry too far in the future")
 65	}
 66	lastN := lastRelayNonce(player)
 67	if nonce <= lastN {
 68		panic("relay voucher already used (nonce " + strconv.FormatInt(nonce, 10) + " ≤ last " + strconv.FormatInt(lastN, 10) + ")")
 69	}
 70	sig, err := hex.DecodeString(sigHex)
 71	if err != nil || len(sig) != 64 {
 72		panic("invalid relay voucher signature encoding")
 73	}
 74	msg := relayVoucherMessage(kind, player, extra, nonce, expiry)
 75	if !ed25519.Verify(claimSignerPubKey, []byte(msg), sig) {
 76		panic("invalid relay voucher signature")
 77	}
 78	relayNonces.Set(player, strconv.FormatInt(nonce, 10))
 79}
 80
 81// requireRelay panics unless the caller is the registered relay address.
 82func requireRelay(cur realm) {
 83	if relayAddress == address("") {
 84		panic("relay not configured: SetRelayAddress first")
 85	}
 86	if cur.Previous().Address() != relayAddress {
 87		panic("unauthorized: relay calls must come from the registered relay address")
 88	}
 89}
 90
 91// ── Admin ─────────────────────────────────────────────────────────────────────
 92
 93// SetRelayAddress registers the treasury wallet address as the relay.
 94// Pass "" to disable the relay.
 95func SetRelayAddress(cur realm, addr string) {
 96	requireAdmin(cur)
 97	if addr == "" {
 98		relayAddress = address("")
 99		return
100	}
101	a := address(addr)
102	if !a.IsValid() {
103		panic("invalid relay address")
104	}
105	relayAddress = a
106	chain.Emit("RelayAddressSet", "relay", addr)
107}
108
109func RelayAddress() string { return relayAddress.String() }
110
111// ── RelayBuyTool ──────────────────────────────────────────────────────────────
112
113// RelayBuyTool executes BuyTool logic on behalf of `player` (the relay pays
114// gas). The server must sign a relay voucher with MOJI_CLAIM_SIGNER_SEED:
115//
116//	gnomoji-relay-v1|<chainId>|BuyTool|<player>|<toolId>|<nonce>|<expiry>
117//
118// All prerequisites (coin balance, tool chain, existing ownership) are checked
119// the same as BuyTool — the relay cannot bypass them.
120func RelayBuyTool(cur realm, player string, toolId string, nonce int64, expiry int64, sigHex string) {
121	requireRelay(cur)
122	requireNotPaused()
123	if isBlacklisted(player) {
124		panic("player address is banned")
125	}
126	if !address(player).IsValid() {
127		panic("invalid player address")
128	}
129	useRelayVoucher("BuyTool", player, toolId, nonce, expiry, sigHex)
130
131	// ── BuyTool logic (identical to BuyTool but acts on `player`, not caller) ──
132	costs, hasCost := toolCosts[toolId]
133	if !hasCost {
134		panic("unknown tool: " + toolId)
135	}
136	if ownsTool(player, toolId) {
137		panic("already own " + toolId + " — use RepairTool to restore durability")
138	}
139	if prereq, ok := toolRequires[toolId]; ok && !ownsTool(player, prereq) {
140		panic("must own " + prereq + " before buying " + toolId)
141	}
142	deductBalances(player, costs)
143
144	if !ownedTools.Has(player) || ownedTools.Get(player).(string) == "" {
145		ownedTools.Set(player, toolId)
146	} else {
147		ownedTools.Set(player, ownedTools.Get(player).(string)+","+toolId)
148	}
149	if mx, ok := toolMaxLife[toolId]; ok && mx > 0 {
150		setU(&toolLife, player+":"+toolId, effectiveMaxLife(player, toolId))
151	}
152	chain.Emit("RelayedBuyTool", "player", player, "tool", toolId, "relay", cur.Previous().Address().String())
153}
154
155// ── RelayTrainSkill ───────────────────────────────────────────────────────────
156
157// RelayTrainSkill executes TrainSkill logic on behalf of `player`.
158// Relay voucher message:
159//
160//	gnomoji-relay-v1|<chainId>|TrainSkill|<player>|<skillId>|<nonce>|<expiry>
161//
162// Same checks as TrainSkill; max rank and coin deduction apply normally.
163func RelayTrainSkill(cur realm, player string, skillId string, nonce int64, expiry int64, sigHex string) {
164	requireRelay(cur)
165	requireNotPaused()
166	if isBlacklisted(player) {
167		panic("player address is banned")
168	}
169	if !address(player).IsValid() {
170		panic("invalid player address")
171	}
172	useRelayVoucher("TrainSkill", player, skillId, nonce, expiry, sigHex)
173
174	// ── TrainSkill logic (identical to TrainSkill but acts on `player`) ──────
175	key := player + ":" + skillId
176	rank := getU(&skillRanks, key)
177	maxRank, ok := skillMaxRanks[skillId]
178	if !ok {
179		panic("unknown skill: " + skillId)
180	}
181	if rank >= maxRank {
182		panic("skill already at maximum rank")
183	}
184	baseCosts, ok := skillBaseCosts[skillId]
185	if !ok {
186		panic("unknown skill: " + skillId)
187	}
188	mult := uint64(1) << rank
189	var scaled [][2]string
190	for _, pair := range baseCosts {
191		baseAmt, _ := strconv.ParseUint(pair[1], 10, 64)
192		if s := baseAmt * mult; s > 0 {
193			scaled = append(scaled, [2]string{pair[0], strconv.FormatUint(s, 10)})
194		}
195	}
196	deductBalances(player, scaled)
197	setU(&skillRanks, key, rank+1)
198	chain.Emit("RelayedTrainSkill", "player", player, "skill", skillId, "relay", cur.Previous().Address().String())
199}
200
201// LastRelayNonce returns the last relay nonce used by addr (0 if none).
202// Used by the server to compute the next nonce.
203func LastRelayNonce(addr string) int64 { return lastRelayNonce(addr) }