game_relay.gno
7.30 Kb · 203 lines
1package game32
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) }