solanapolis 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +518 -0
- package/bin/solanapolis.js +197 -0
- package/convex/_generated/api.d.ts +175 -0
- package/convex/_generated/api.js +23 -0
- package/convex/_generated/dataModel.d.ts +60 -0
- package/convex/_generated/server.d.ts +143 -0
- package/convex/_generated/server.js +93 -0
- package/convex/agent/conversation.ts +352 -0
- package/convex/agent/embeddingsCache.ts +110 -0
- package/convex/agent/memory.ts +450 -0
- package/convex/agent/schema.ts +53 -0
- package/convex/aiChat.ts +54 -0
- package/convex/aiTown/agent.ts +382 -0
- package/convex/aiTown/agentDescription.ts +27 -0
- package/convex/aiTown/agentInputs.ts +155 -0
- package/convex/aiTown/agentOperations.ts +178 -0
- package/convex/aiTown/conversation.ts +395 -0
- package/convex/aiTown/conversationMembership.ts +38 -0
- package/convex/aiTown/game.ts +371 -0
- package/convex/aiTown/ids.ts +32 -0
- package/convex/aiTown/inputHandler.ts +9 -0
- package/convex/aiTown/inputs.ts +25 -0
- package/convex/aiTown/insertInput.ts +20 -0
- package/convex/aiTown/location.ts +32 -0
- package/convex/aiTown/main.ts +154 -0
- package/convex/aiTown/movement.ts +189 -0
- package/convex/aiTown/player.ts +310 -0
- package/convex/aiTown/playerDescription.ts +35 -0
- package/convex/aiTown/schema.ts +79 -0
- package/convex/aiTown/world.ts +65 -0
- package/convex/aiTown/worldMap.ts +74 -0
- package/convex/chat.ts +79 -0
- package/convex/constants.ts +78 -0
- package/convex/convex.config.ts +6 -0
- package/convex/crons.ts +89 -0
- package/convex/engine/abstractGame.ts +199 -0
- package/convex/engine/historicalObject.ts +355 -0
- package/convex/engine/schema.ts +56 -0
- package/convex/http.ts +36 -0
- package/convex/init.ts +110 -0
- package/convex/messages.ts +53 -0
- package/convex/npcCarAgents.ts +415 -0
- package/convex/schema.ts +61 -0
- package/convex/streaming.ts +23 -0
- package/convex/testing.ts +202 -0
- package/convex/tsconfig.json +18 -0
- package/convex/util/FastIntegerCompression.ts +221 -0
- package/convex/util/assertNever.ts +4 -0
- package/convex/util/asyncMap.ts +20 -0
- package/convex/util/compression.ts +71 -0
- package/convex/util/geometry.ts +132 -0
- package/convex/util/isSimpleObject.ts +11 -0
- package/convex/util/llm.ts +724 -0
- package/convex/util/minheap.ts +38 -0
- package/convex/util/object.ts +22 -0
- package/convex/util/sleep.ts +3 -0
- package/convex/util/types.ts +33 -0
- package/convex/util/xxhash.ts +228 -0
- package/convex/world.ts +257 -0
- package/data/animations/campfire.json +45 -0
- package/data/animations/gentlesparkle.json +37 -0
- package/data/animations/gentlesplash.json +61 -0
- package/data/animations/gentlewaterfall.json +61 -0
- package/data/animations/windmill.json +78 -0
- package/data/characters.ts +121 -0
- package/data/convertMap.js +74 -0
- package/data/gentle.js +330 -0
- package/data/spritesheets/f1.ts +75 -0
- package/data/spritesheets/f2.ts +75 -0
- package/data/spritesheets/f3.ts +75 -0
- package/data/spritesheets/f4.ts +75 -0
- package/data/spritesheets/f5.ts +75 -0
- package/data/spritesheets/f6.ts +75 -0
- package/data/spritesheets/f7.ts +75 -0
- package/data/spritesheets/f8.ts +75 -0
- package/data/spritesheets/p1.ts +59 -0
- package/data/spritesheets/p2.ts +59 -0
- package/data/spritesheets/p3.ts +59 -0
- package/data/spritesheets/player.ts +59 -0
- package/data/spritesheets/types.ts +26 -0
- package/eslint.config.mjs +37 -0
- package/next.config.ts +7 -0
- package/package.json +85 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/helius-icon.svg +84 -0
- package/public/helius-logo.svg +85 -0
- package/public/next.svg +1 -0
- package/public/plane.glb +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/clear-city.ts +74 -0
- package/scripts/seed-wallets.ts +185 -0
- package/scripts/setup-webhook.ts +73 -0
- package/src/app/api/auth/callback/route.ts +6 -0
- package/src/app/api/auth/link-wallet/route.ts +6 -0
- package/src/app/api/auth/phantom/route.ts +6 -0
- package/src/app/api/broadcast-position/route.ts +59 -0
- package/src/app/api/leaderboard/route.ts +85 -0
- package/src/app/api/network-stats/route.ts +86 -0
- package/src/app/api/parcel-reward/route.ts +181 -0
- package/src/app/api/queue-status/route.ts +30 -0
- package/src/app/api/snapshots/route.ts +37 -0
- package/src/app/api/transactions/enhanced/route.ts +57 -0
- package/src/app/api/treasury/route.ts +83 -0
- package/src/app/api/wallet/[address]/balances/route.ts +124 -0
- package/src/app/api/wallet/[address]/identity/route.ts +32 -0
- package/src/app/api/wallet/[address]/route.ts +216 -0
- package/src/app/api/wallet/[address]/traded-tokens/route.ts +41 -0
- package/src/app/api/wallets/route.ts +68 -0
- package/src/app/api/webhooks/helius/route.ts +76 -0
- package/src/app/auth/callback/page.tsx +29 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +39 -0
- package/src/app/layout.tsx +43 -0
- package/src/app/page.tsx +16 -0
- package/src/components/AITownNPCs.tsx +206 -0
- package/src/components/ActivityFeed.tsx +189 -0
- package/src/components/AuthPanel.tsx +163 -0
- package/src/components/BeachScene.tsx +280 -0
- package/src/components/Building.tsx +138 -0
- package/src/components/CesiumFlight.tsx +1768 -0
- package/src/components/CesiumGlobe.tsx +616 -0
- package/src/components/CitizenCard.tsx +442 -0
- package/src/components/CitizenCardModal.tsx +153 -0
- package/src/components/CityGrid.tsx +313 -0
- package/src/components/CityLandmarks.tsx +427 -0
- package/src/components/CityScene.tsx +1289 -0
- package/src/components/CitySlotsBadge.tsx +68 -0
- package/src/components/CockpitHUD.tsx +460 -0
- package/src/components/ConvexWrapper.tsx +19 -0
- package/src/components/DubaiDistrict.tsx +630 -0
- package/src/components/FlightMiniMap.tsx +133 -0
- package/src/components/GameChat.tsx +383 -0
- package/src/components/GameHUD.tsx +393 -0
- package/src/components/Ground.tsx +14 -0
- package/src/components/HowItWorksModal.tsx +251 -0
- package/src/components/IngestionBanner.tsx +123 -0
- package/src/components/InstancedBuildings.tsx +316 -0
- package/src/components/InstancedCars.tsx +504 -0
- package/src/components/InstancedCityPlanes.tsx +259 -0
- package/src/components/InstancedHouses.tsx +246 -0
- package/src/components/InstancedLampPosts.tsx +201 -0
- package/src/components/InstancedResidentCars.tsx +357 -0
- package/src/components/InstancedRoadDashes.tsx +42 -0
- package/src/components/InstancedSkyscrapers.tsx +434 -0
- package/src/components/InstancedTrees.tsx +67 -0
- package/src/components/LeaderboardPanel.tsx +136 -0
- package/src/components/MultiplayerPlanes.tsx +128 -0
- package/src/components/NetworkStats.tsx +83 -0
- package/src/components/NewBuildingSpotlight.tsx +93 -0
- package/src/components/ParcelChallengeBanner.tsx +242 -0
- package/src/components/ParcelReward.tsx +191 -0
- package/src/components/Park.tsx +42 -0
- package/src/components/PhantomWrapper.tsx +22 -0
- package/src/components/PixelStreamViewer.tsx +335 -0
- package/src/components/PlaneMode.tsx +190 -0
- package/src/components/PlayerCar.tsx +211 -0
- package/src/components/PlayerPlane.tsx +255 -0
- package/src/components/ProjectileRenderer.tsx +249 -0
- package/src/components/QueueStatusBanner.tsx +86 -0
- package/src/components/RealPlayerTags.tsx +82 -0
- package/src/components/SceneLighting.tsx +382 -0
- package/src/components/SelectionBeam.tsx +59 -0
- package/src/components/SwapPanel.tsx +104 -0
- package/src/components/SwapParticles.tsx +237 -0
- package/src/components/TreasureGate.tsx +505 -0
- package/src/components/WalletPanel.tsx +421 -0
- package/src/components/WalletSearch.tsx +244 -0
- package/src/components/WelcomeOverlay.tsx +135 -0
- package/src/components/WindowTooltip.tsx +498 -0
- package/src/context/AuthContext.tsx +230 -0
- package/src/lib/bot-detection.ts +125 -0
- package/src/lib/building-math.ts +136 -0
- package/src/lib/building-shader.ts +253 -0
- package/src/lib/car-paths.ts +244 -0
- package/src/lib/car-system.ts +182 -0
- package/src/lib/city-constants.ts +29 -0
- package/src/lib/city-slots.ts +35 -0
- package/src/lib/city-zoning.ts +64 -0
- package/src/lib/collision-map.ts +147 -0
- package/src/lib/day-night.ts +252 -0
- package/src/lib/export-card.ts +28 -0
- package/src/lib/helius-webhook.ts +90 -0
- package/src/lib/helius.ts +74 -0
- package/src/lib/house-shader.ts +119 -0
- package/src/lib/mock-data.ts +56 -0
- package/src/lib/multiplayer-manager.ts +329 -0
- package/src/lib/plane-physics.ts +66 -0
- package/src/lib/player-car.ts +147 -0
- package/src/lib/player-plane.ts +200 -0
- package/src/lib/projectile-system.ts +272 -0
- package/src/lib/skyscraper-types.ts +52 -0
- package/src/lib/sound-engine.ts +464 -0
- package/src/lib/supabase-admin.ts +9 -0
- package/src/lib/supabase.ts +8 -0
- package/src/lib/swap-events.ts +70 -0
- package/src/middleware.ts +37 -0
- package/src/types/phantom.d.ts +16 -0
- package/src/types/wallet.ts +20 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useState,
|
|
8
|
+
useCallback,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { createClient } from "@/lib/supabase";
|
|
12
|
+
import type { User } from "@supabase/supabase-js";
|
|
13
|
+
import { usePhantom, useModal } from "@phantom/react-sdk";
|
|
14
|
+
|
|
15
|
+
interface Profile {
|
|
16
|
+
id: string;
|
|
17
|
+
wallet_address: string | null;
|
|
18
|
+
x_username: string | null;
|
|
19
|
+
x_avatar_url: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface PhantomSdkAddress {
|
|
23
|
+
type?: string;
|
|
24
|
+
address?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PhantomSdkUserLike {
|
|
28
|
+
solana?: {
|
|
29
|
+
address?: string;
|
|
30
|
+
};
|
|
31
|
+
addresses?: PhantomSdkAddress[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface AuthContextValue {
|
|
35
|
+
user: User | null;
|
|
36
|
+
profile: Profile | null;
|
|
37
|
+
loading: boolean;
|
|
38
|
+
connectPhantom: () => Promise<void>;
|
|
39
|
+
connectX: () => Promise<void>;
|
|
40
|
+
linkWallet: () => Promise<void>;
|
|
41
|
+
signOut: () => Promise<void>;
|
|
42
|
+
phantomAddress: string | null;
|
|
43
|
+
phantomConnected: boolean;
|
|
44
|
+
openPhantomModal: () => void;
|
|
45
|
+
closePhantomModal: () => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
49
|
+
|
|
50
|
+
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
51
|
+
const [supabase] = useState(() => createClient());
|
|
52
|
+
const [user, setUser] = useState<User | null>(null);
|
|
53
|
+
const [profile, setProfile] = useState<Profile | null>(null);
|
|
54
|
+
const [loading, setLoading] = useState(true);
|
|
55
|
+
|
|
56
|
+
const { isConnected: phantomConnected, user: phantomUser } = usePhantom();
|
|
57
|
+
const { open: openPhantomModal, close: closePhantomModal } = useModal();
|
|
58
|
+
const phantomSdkUser = phantomUser as PhantomSdkUserLike | null;
|
|
59
|
+
const phantomAddress: string | null =
|
|
60
|
+
phantomSdkUser?.solana?.address ??
|
|
61
|
+
phantomSdkUser?.addresses?.find((a) => a.type === "solana")?.address ??
|
|
62
|
+
null;
|
|
63
|
+
|
|
64
|
+
const fetchProfile = useCallback(
|
|
65
|
+
async (userId: string) => {
|
|
66
|
+
const { data } = await supabase
|
|
67
|
+
.from("profiles")
|
|
68
|
+
.select("*")
|
|
69
|
+
.eq("id", userId)
|
|
70
|
+
.single();
|
|
71
|
+
setProfile(data);
|
|
72
|
+
},
|
|
73
|
+
[supabase]
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
supabase.auth.getUser().then(({ data: { user } }) => {
|
|
78
|
+
setUser(user ?? null);
|
|
79
|
+
if (user) fetchProfile(user.id);
|
|
80
|
+
setLoading(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const {
|
|
84
|
+
data: { subscription },
|
|
85
|
+
} = supabase.auth.onAuthStateChange((_event, session) => {
|
|
86
|
+
const newUser = session?.user ?? null;
|
|
87
|
+
setUser(newUser);
|
|
88
|
+
if (newUser) {
|
|
89
|
+
fetchProfile(newUser.id);
|
|
90
|
+
} else {
|
|
91
|
+
setProfile(null);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return () => subscription.unsubscribe();
|
|
96
|
+
}, [supabase, fetchProfile]);
|
|
97
|
+
|
|
98
|
+
const connectPhantom = useCallback(async () => {
|
|
99
|
+
const provider = window.phantom?.solana;
|
|
100
|
+
if (!provider?.isPhantom) {
|
|
101
|
+
window.open("https://phantom.app/", "_blank");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { publicKey } = await provider.connect();
|
|
106
|
+
const walletAddress = publicKey.toString();
|
|
107
|
+
|
|
108
|
+
const timestamp = Date.now();
|
|
109
|
+
const message = `Sign in to Solanapolis\nWallet: ${walletAddress}\nTimestamp: ${timestamp}`;
|
|
110
|
+
const encodedMessage = new TextEncoder().encode(message);
|
|
111
|
+
|
|
112
|
+
const { signature } = await provider.signMessage(encodedMessage, "utf8");
|
|
113
|
+
|
|
114
|
+
// Encode as base64 for transport
|
|
115
|
+
const signatureBase64 = btoa(String.fromCharCode(...signature));
|
|
116
|
+
const publicKeyBase64 = btoa(String.fromCharCode(...publicKey.toBytes()));
|
|
117
|
+
|
|
118
|
+
const res = await fetch("/api/auth/phantom", {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { "Content-Type": "application/json" },
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
walletAddress,
|
|
123
|
+
publicKey: publicKeyBase64,
|
|
124
|
+
signature: signatureBase64,
|
|
125
|
+
message,
|
|
126
|
+
timestamp,
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
const err = await res.json();
|
|
132
|
+
throw new Error(err.error ?? "Phantom auth failed");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { token_hash } = await res.json();
|
|
136
|
+
|
|
137
|
+
const { error } = await supabase.auth.verifyOtp({
|
|
138
|
+
token_hash,
|
|
139
|
+
type: "magiclink",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (error) throw error;
|
|
143
|
+
}, [supabase]);
|
|
144
|
+
|
|
145
|
+
const connectX = useCallback(async () => {
|
|
146
|
+
// If already signed in (e.g. via Phantom), stash wallet so the callback
|
|
147
|
+
// can transfer it to the X-authenticated user, then sign out first.
|
|
148
|
+
if (user && profile?.wallet_address) {
|
|
149
|
+
document.cookie = `pendingWalletLink=${profile.wallet_address}; path=/; max-age=300; samesite=lax`;
|
|
150
|
+
await supabase.auth.signOut();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Always use signInWithOAuth — avoids identity_already_exists errors
|
|
154
|
+
const { error } = await supabase.auth.signInWithOAuth({
|
|
155
|
+
provider: "x",
|
|
156
|
+
options: {
|
|
157
|
+
redirectTo: `${window.location.origin}/api/auth/callback`,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
if (error) throw error;
|
|
161
|
+
}, [supabase, user, profile]);
|
|
162
|
+
|
|
163
|
+
const linkWallet = useCallback(async () => {
|
|
164
|
+
if (!user) return;
|
|
165
|
+
|
|
166
|
+
const provider = window.phantom?.solana;
|
|
167
|
+
if (!provider?.isPhantom) {
|
|
168
|
+
window.open("https://phantom.app/", "_blank");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const { publicKey } = await provider.connect();
|
|
173
|
+
const walletAddress = publicKey.toString();
|
|
174
|
+
|
|
175
|
+
const timestamp = Date.now();
|
|
176
|
+
const message = `Link wallet to Solanapolis\nWallet: ${walletAddress}\nTimestamp: ${timestamp}`;
|
|
177
|
+
const encodedMessage = new TextEncoder().encode(message);
|
|
178
|
+
|
|
179
|
+
const { signature } = await provider.signMessage(encodedMessage, "utf8");
|
|
180
|
+
|
|
181
|
+
const signatureBase64 = btoa(String.fromCharCode(...signature));
|
|
182
|
+
const publicKeyBase64 = btoa(String.fromCharCode(...publicKey.toBytes()));
|
|
183
|
+
|
|
184
|
+
const res = await fetch("/api/auth/link-wallet", {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: { "Content-Type": "application/json" },
|
|
187
|
+
body: JSON.stringify({
|
|
188
|
+
walletAddress,
|
|
189
|
+
publicKey: publicKeyBase64,
|
|
190
|
+
signature: signatureBase64,
|
|
191
|
+
message,
|
|
192
|
+
timestamp,
|
|
193
|
+
}),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
const err = await res.json();
|
|
198
|
+
throw new Error(err.error ?? "Failed to link wallet");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Refresh profile to pick up the new wallet_address
|
|
202
|
+
await fetchProfile(user.id);
|
|
203
|
+
}, [user, fetchProfile]);
|
|
204
|
+
|
|
205
|
+
const signOut = useCallback(async () => {
|
|
206
|
+
// Disconnect Phantom if connected
|
|
207
|
+
try {
|
|
208
|
+
await window.phantom?.solana?.disconnect();
|
|
209
|
+
} catch {
|
|
210
|
+
// ignore
|
|
211
|
+
}
|
|
212
|
+
await supabase.auth.signOut();
|
|
213
|
+
setUser(null);
|
|
214
|
+
setProfile(null);
|
|
215
|
+
}, [supabase]);
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<AuthContext.Provider
|
|
219
|
+
value={{ user, profile, loading, connectPhantom, connectX, linkWallet, signOut, phantomAddress, phantomConnected, openPhantomModal, closePhantomModal }}
|
|
220
|
+
>
|
|
221
|
+
{children}
|
|
222
|
+
</AuthContext.Provider>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function useAuth() {
|
|
227
|
+
const ctx = useContext(AuthContext);
|
|
228
|
+
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
|
229
|
+
return ctx;
|
|
230
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const HELIUS_API_KEY = process.env.HELIUS_API_KEY!;
|
|
2
|
+
const HELIUS_RPC_URL = `https://mainnet.helius-rpc.com/?api-key=${HELIUS_API_KEY}`;
|
|
3
|
+
|
|
4
|
+
const BOT_DETECTION_TXS_PER_MINUTE = 20;
|
|
5
|
+
const MIN_TXS_FOR_BOT_CHECK = 100;
|
|
6
|
+
|
|
7
|
+
interface HeliusSignatureEntry {
|
|
8
|
+
signature: string;
|
|
9
|
+
blockTime: number | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function fetchTransactionsFromHelius(
|
|
13
|
+
address: string,
|
|
14
|
+
limit: number,
|
|
15
|
+
sortOrder: "asc" | "desc",
|
|
16
|
+
): Promise<HeliusSignatureEntry[]> {
|
|
17
|
+
const res = await fetch(HELIUS_RPC_URL, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: { "Content-Type": "application/json" },
|
|
20
|
+
body: JSON.stringify({
|
|
21
|
+
jsonrpc: "2.0",
|
|
22
|
+
id: 1,
|
|
23
|
+
method: "getTransactionsForAddress",
|
|
24
|
+
params: [
|
|
25
|
+
address,
|
|
26
|
+
{
|
|
27
|
+
sortOrder,
|
|
28
|
+
limit,
|
|
29
|
+
transactionDetails: "full",
|
|
30
|
+
encoding: "json",
|
|
31
|
+
maxSupportedTransactionVersion: 0,
|
|
32
|
+
filters: {
|
|
33
|
+
status: "succeeded",
|
|
34
|
+
tokenAccounts: "balanceChanged",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
throw new Error(`Helius RPC error: ${res.status} ${res.statusText}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const json = await res.json();
|
|
46
|
+
if (json.error) {
|
|
47
|
+
throw new Error(`Helius RPC error: ${json.error.message}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return json.result?.data ?? [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface DensityResult {
|
|
54
|
+
isBot: boolean;
|
|
55
|
+
txsPerMinute: number;
|
|
56
|
+
label: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function checkBotDensity(
|
|
60
|
+
transactions: HeliusSignatureEntry[],
|
|
61
|
+
label: string,
|
|
62
|
+
): DensityResult {
|
|
63
|
+
// Filter out transactions with no blockTime
|
|
64
|
+
const valid = transactions.filter((tx) => tx.blockTime != null && tx.blockTime > 0);
|
|
65
|
+
if (valid.length < 2) {
|
|
66
|
+
return { isBot: false, txsPerMinute: 0, label };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Sort by blockTime ascending
|
|
70
|
+
valid.sort((a, b) => a.blockTime! - b.blockTime!);
|
|
71
|
+
|
|
72
|
+
const oldest = valid[0].blockTime!;
|
|
73
|
+
const newest = valid[valid.length - 1].blockTime!;
|
|
74
|
+
const timeSpanMinutes = (newest - oldest) / 60;
|
|
75
|
+
|
|
76
|
+
// All txns in the same block/second — treat as bot
|
|
77
|
+
if (timeSpanMinutes === 0) {
|
|
78
|
+
return { isBot: true, txsPerMinute: Infinity, label };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const txsPerMinute = valid.length / timeSpanMinutes;
|
|
82
|
+
return {
|
|
83
|
+
isBot: txsPerMinute >= BOT_DETECTION_TXS_PER_MINUTE,
|
|
84
|
+
txsPerMinute,
|
|
85
|
+
label,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface BotDetectionResult {
|
|
90
|
+
isBot: boolean;
|
|
91
|
+
reason?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function detectBotActivity(
|
|
95
|
+
address: string,
|
|
96
|
+
): Promise<BotDetectionResult> {
|
|
97
|
+
// Fetch oldest 100 transactions first
|
|
98
|
+
const oldest = await fetchTransactionsFromHelius(address, MIN_TXS_FOR_BOT_CHECK, "asc");
|
|
99
|
+
|
|
100
|
+
// If fewer than 100 total transactions, skip bot check
|
|
101
|
+
if (oldest.length < MIN_TXS_FOR_BOT_CHECK) {
|
|
102
|
+
return { isBot: false };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const oldestCheck = checkBotDensity(oldest, "oldest");
|
|
106
|
+
if (oldestCheck.isBot) {
|
|
107
|
+
return {
|
|
108
|
+
isBot: true,
|
|
109
|
+
reason: `High tx density in oldest transactions: ${oldestCheck.txsPerMinute.toFixed(1)} txns/min`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Fetch newest 100 transactions
|
|
114
|
+
const newest = await fetchTransactionsFromHelius(address, MIN_TXS_FOR_BOT_CHECK, "desc");
|
|
115
|
+
|
|
116
|
+
const newestCheck = checkBotDensity(newest, "newest");
|
|
117
|
+
if (newestCheck.isBot) {
|
|
118
|
+
return {
|
|
119
|
+
isBot: true,
|
|
120
|
+
reason: `High tx density in newest transactions: ${newestCheck.txsPerMinute.toFixed(1)} txns/min`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { isBot: false };
|
|
125
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { WalletBuilding, PlacedWallet } from "@/types/wallet";
|
|
2
|
+
import { CELL_SIZE, ROAD_WIDTH, BLOCK_SIZE, BLOCK_STRIDE, BLOCKS_PER_ROW, GRID_WORLD } from "./city-constants";
|
|
3
|
+
import { SkyscraperType } from "./skyscraper-types";
|
|
4
|
+
import { getBlockZone } from "./city-zoning";
|
|
5
|
+
|
|
6
|
+
// --- Height: txn count → floors (1–150) → world height ---
|
|
7
|
+
|
|
8
|
+
const FLOOR_HEIGHT = 0.3; // world units per floor
|
|
9
|
+
|
|
10
|
+
export function floors(txns: number): number {
|
|
11
|
+
// Map 10 → 500k into 0..1 in log-space, then apply x^2 easing
|
|
12
|
+
// so low-txn wallets stay short and only 100k+ get truly tall.
|
|
13
|
+
txns = Math.max(10, Math.min(txns, 500_000));
|
|
14
|
+
const x = (Math.log10(txns) - 1) / (Math.log10(500_000) - 1); // 0..1 over [10, 500k]
|
|
15
|
+
const eased = x * x; // quadratic — spreads out mid-to-high range
|
|
16
|
+
return Math.round(1 + eased * 149);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// --- Width: volume → 0.6–3.0 world units (scaled to fit cell grid) ---
|
|
20
|
+
// Spec maps 4–20 abstract units; we remap to 0.6–3.0 to fit CELL_SIZE=4
|
|
21
|
+
|
|
22
|
+
const WIDTH_WORLD_MIN = 0.6;
|
|
23
|
+
const WIDTH_WORLD_MAX = 3.0;
|
|
24
|
+
|
|
25
|
+
function buildingWidth(volume: number, maxVolume: number): number {
|
|
26
|
+
if (maxVolume <= 0) return WIDTH_WORLD_MIN;
|
|
27
|
+
const x = Math.log10(volume + 1) / Math.log10(maxVolume + 1);
|
|
28
|
+
const abstract = 4 + 16 * Math.sqrt(x); // 4–20
|
|
29
|
+
// Remap 4–20 → 0.6–3.0
|
|
30
|
+
const t = (abstract - 4) / 16;
|
|
31
|
+
return WIDTH_WORLD_MIN + t * (WIDTH_WORLD_MAX - WIDTH_WORLD_MIN);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Depth = width (square footprint per spec)
|
|
35
|
+
|
|
36
|
+
// We need maxVolume across all wallets for the width formula.
|
|
37
|
+
// Compute it once and cache.
|
|
38
|
+
let cachedMaxVolume = 0;
|
|
39
|
+
|
|
40
|
+
export function setMaxVolume(wallets: WalletBuilding[]) {
|
|
41
|
+
cachedMaxVolume = 0;
|
|
42
|
+
for (const w of wallets) {
|
|
43
|
+
if (w.volumeTraded > cachedMaxVolume) cachedMaxVolume = w.volumeTraded;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getBuildingDimensions(wallet: WalletBuilding) {
|
|
48
|
+
const height = floors(wallet.txnCount) * FLOOR_HEIGHT;
|
|
49
|
+
const width = buildingWidth(wallet.volumeTraded, cachedMaxVolume);
|
|
50
|
+
const depth = width; // square footprint
|
|
51
|
+
return { height, width, depth };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Color based on height — realistic urban palette
|
|
55
|
+
// Short buildings: warm sandstone/brick tones
|
|
56
|
+
// Mid-rise: concrete/tan
|
|
57
|
+
// Tall buildings: cool slate gray / steel blue
|
|
58
|
+
const COLOR_HEIGHT_MAX = 150 * FLOOR_HEIGHT; // 45 units (150 floors)
|
|
59
|
+
|
|
60
|
+
/** World-space position for a placed wallet (matches InstancedBuildings) */
|
|
61
|
+
export function getWalletWorldPosition(
|
|
62
|
+
w: PlacedWallet,
|
|
63
|
+
dims: { width: number; depth: number; height: number },
|
|
64
|
+
): [number, number, number] {
|
|
65
|
+
const offset = -GRID_WORLD / 2 + BLOCK_SIZE / 2 + ROAD_WIDTH / 2;
|
|
66
|
+
const blockOriginX = offset + w.blockCol * BLOCK_STRIDE - BLOCK_SIZE / 2;
|
|
67
|
+
const blockOriginZ = offset + w.blockRow * BLOCK_STRIDE - BLOCK_SIZE / 2;
|
|
68
|
+
const localRow = Math.floor(w.localSlot / 4);
|
|
69
|
+
const localCol = w.localSlot % 4;
|
|
70
|
+
return [
|
|
71
|
+
blockOriginX + localCol * CELL_SIZE + dims.width / 2 + 0.5,
|
|
72
|
+
dims.height / 2,
|
|
73
|
+
blockOriginZ + localRow * CELL_SIZE + dims.depth / 2 + 0.5,
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- Window math ---
|
|
78
|
+
|
|
79
|
+
export function getWindowCols(buildingWidth: number): number {
|
|
80
|
+
return Math.max(2, Math.min(7, Math.round(buildingWidth * 2.5)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getWindowFillRatio(uniqueTokensSwapped: number): number {
|
|
84
|
+
if (uniqueTokensSwapped <= 0) return 0;
|
|
85
|
+
return Math.max(0.02, Math.min(1.0, uniqueTokensSwapped / (uniqueTokensSwapped + 500)));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getLitRatio(latestBlocktime: number | null | undefined): number {
|
|
89
|
+
if (latestBlocktime == null) return 0.05;
|
|
90
|
+
const nowSeconds = Date.now() / 1000;
|
|
91
|
+
const ageDays = (nowSeconds - latestBlocktime) / 86400;
|
|
92
|
+
return Math.min(0.45, Math.max(0.05, 0.45 * Math.exp(-ageDays / 14)));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function getInstanceSeed(address: string): number {
|
|
96
|
+
let hash = 0;
|
|
97
|
+
for (let i = 0; i < address.length; i++) {
|
|
98
|
+
hash = (hash * 31 + address.charCodeAt(i)) | 0;
|
|
99
|
+
}
|
|
100
|
+
return Math.abs(hash % 10007) / 10007;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function getWindowRows(floorCount: number): number {
|
|
104
|
+
return Math.min(floorCount, 35);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- Skyscraper type assignment ---
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Deterministically assign a skyscraper type to a downtown wallet.
|
|
111
|
+
* Non-downtown wallets always get "box".
|
|
112
|
+
*/
|
|
113
|
+
export function getSkyscraperType(wallet: PlacedWallet): SkyscraperType {
|
|
114
|
+
const zone = getBlockZone(wallet.blockRow, wallet.blockCol, BLOCKS_PER_ROW);
|
|
115
|
+
if (zone.zone !== "downtown") return "box";
|
|
116
|
+
|
|
117
|
+
const seed = getInstanceSeed(wallet.address);
|
|
118
|
+
if (seed < 0.40) return "box";
|
|
119
|
+
if (seed < 0.55) return "setback";
|
|
120
|
+
if (seed < 0.70) return "twin";
|
|
121
|
+
if (seed < 0.85) return "cantilever";
|
|
122
|
+
return "spire";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function getBuildingColor(height: number): string {
|
|
126
|
+
const t = Math.min(1, height / COLOR_HEIGHT_MAX);
|
|
127
|
+
|
|
128
|
+
// Short (t≈0): lighter concrete rgb(100, 98, 110)
|
|
129
|
+
// Mid (t≈0.5): mid slate rgb(82, 82, 105)
|
|
130
|
+
// Tall (t≈1): near ground tone rgb(65, 68, 95)
|
|
131
|
+
// Ground is #4a4a65 = rgb(74, 74, 101)
|
|
132
|
+
const r = Math.floor(100 - t * 35);
|
|
133
|
+
const g = Math.floor(98 - t * 30);
|
|
134
|
+
const b = Math.floor(110 - t * 15);
|
|
135
|
+
return `rgb(${r},${g},${b})`;
|
|
136
|
+
}
|