From f5d14ffebe7ad91f868b5660aa5d1b28c860ab45 Mon Sep 17 00:00:00 2001 From: rndrntwrk <180591682+rndrntwrk@users.noreply.github.com> Date: Mon, 30 Mar 2026 03:15:24 -0500 Subject: [PATCH 001/161] fix: split shared rpc reads from submit paths --- packages/hyperbet-avax/keeper/src/service.ts | 33 +++- packages/hyperbet-bsc/keeper/src/service.ts | 33 +++- packages/hyperbet-evm/keeper/src/service.ts | 33 +++- packages/hyperbet-mm-core/src/index.ts | 2 + .../src/rpcProxyPolicy.test.ts | 72 +++++++ .../hyperbet-mm-core/src/rpcProxyPolicy.ts | 106 +++++++++++ .../hyperbet-solana/keeper/src/service.ts | 38 +++- packages/hyperbet-ui/src/lib/chainConfig.ts | 14 +- packages/hyperbet-ui/src/lib/config.ts | 26 ++- packages/hyperbet-ui/src/lib/evmClient.ts | 179 ++++++++++++++++-- packages/hyperbet-ui/src/lib/jupiter.ts | 11 +- packages/hyperbet-ui/src/lib/solanaRpc.ts | 57 +++++- packages/hyperbet-ui/src/lib/token.ts | 17 +- packages/hyperbet-ui/src/lib/wagmiConfig.ts | 4 +- packages/hyperbet-ui/tests/solanaRpc.test.ts | 44 +++++ 15 files changed, 608 insertions(+), 61 deletions(-) create mode 100644 packages/hyperbet-mm-core/src/rpcProxyPolicy.test.ts create mode 100644 packages/hyperbet-mm-core/src/rpcProxyPolicy.ts create mode 100644 packages/hyperbet-ui/tests/solanaRpc.test.ts diff --git a/packages/hyperbet-avax/keeper/src/service.ts b/packages/hyperbet-avax/keeper/src/service.ts index 4d9ef5c0..64cfc8d3 100644 --- a/packages/hyperbet-avax/keeper/src/service.ts +++ b/packages/hyperbet-avax/keeper/src/service.ts @@ -27,7 +27,11 @@ import { type VerifiedExternalBetRecord, } from "@hyperbet/evm-keeper-core"; import { + findUnsupportedJsonRpcMethod, + isWriteRateLimitedRoute, mergePredictionMarketsWithHealth, + PUBLIC_EVM_RPC_READ_METHODS, + PUBLIC_SOLANA_RPC_READ_METHODS, type KeeperBotHealthSnapshot, type KeeperMarketHealthRecord, } from "@hyperbet/mm-core"; @@ -2816,6 +2820,19 @@ async function handleSolanaRpcProxy(req: Request): Promise { if (!rpcBody.ok) { return rpcBody.response; } + const unsupportedMethod = findUnsupportedJsonRpcMethod( + rpcBody.requests, + PUBLIC_SOLANA_RPC_READ_METHODS, + ); + if (unsupportedMethod) { + return jsonResponse( + req, + { + error: `JSON-RPC method ${unsupportedMethod} is not allowed on the public Solana RPC proxy`, + }, + 403, + ); + } try { const ttlMs = resolveJsonRpcCacheTtlMs( @@ -2945,6 +2962,19 @@ async function handleEvmRpcProxy(req: Request, url: URL): Promise { if (!rpcBody.ok) { return rpcBody.response; } + const unsupportedMethod = findUnsupportedJsonRpcMethod( + rpcBody.requests, + PUBLIC_EVM_RPC_READ_METHODS, + ); + if (unsupportedMethod) { + return jsonResponse( + req, + { + error: `JSON-RPC method ${unsupportedMethod} is not allowed on the public EVM RPC proxy`, + }, + 403, + ); + } try { const ttlMs = resolveJsonRpcCacheTtlMs( @@ -3100,8 +3130,7 @@ const server = Bun.serve({ development: process.env.NODE_ENV !== "production", fetch: async (req: Request) => { const url = new URL(req.url); - const isWriteRoute = - req.method === "POST" || url.pathname === "/api/streaming/state/publish"; + const isWriteRoute = isWriteRateLimitedRoute(req.method, url.pathname); const allowed = checkRateLimit( req, url.pathname, diff --git a/packages/hyperbet-bsc/keeper/src/service.ts b/packages/hyperbet-bsc/keeper/src/service.ts index b8fd6e34..8ec75b6e 100644 --- a/packages/hyperbet-bsc/keeper/src/service.ts +++ b/packages/hyperbet-bsc/keeper/src/service.ts @@ -28,7 +28,11 @@ import { type VerifiedExternalBetRecord, } from "@hyperbet/evm-keeper-core"; import { + findUnsupportedJsonRpcMethod, + isWriteRateLimitedRoute, mergePredictionMarketsWithHealth, + PUBLIC_EVM_RPC_READ_METHODS, + PUBLIC_SOLANA_RPC_READ_METHODS, type KeeperBotHealthSnapshot, type KeeperMarketHealthRecord, } from "@hyperbet/mm-core"; @@ -2760,6 +2764,19 @@ async function handleSolanaRpcProxy(req: Request): Promise { if (!rpcBody.ok) { return rpcBody.response; } + const unsupportedMethod = findUnsupportedJsonRpcMethod( + rpcBody.requests, + PUBLIC_SOLANA_RPC_READ_METHODS, + ); + if (unsupportedMethod) { + return jsonResponse( + req, + { + error: `JSON-RPC method ${unsupportedMethod} is not allowed on the public Solana RPC proxy`, + }, + 403, + ); + } try { const ttlMs = resolveJsonRpcCacheTtlMs( @@ -2889,6 +2906,19 @@ async function handleEvmRpcProxy(req: Request, url: URL): Promise { if (!rpcBody.ok) { return rpcBody.response; } + const unsupportedMethod = findUnsupportedJsonRpcMethod( + rpcBody.requests, + PUBLIC_EVM_RPC_READ_METHODS, + ); + if (unsupportedMethod) { + return jsonResponse( + req, + { + error: `JSON-RPC method ${unsupportedMethod} is not allowed on the public EVM RPC proxy`, + }, + 403, + ); + } try { const ttlMs = resolveJsonRpcCacheTtlMs( @@ -3044,8 +3074,7 @@ const server = Bun.serve({ development: process.env.NODE_ENV !== "production", fetch: async (req: Request) => { const url = new URL(req.url); - const isWriteRoute = - req.method === "POST" || url.pathname === "/api/streaming/state/publish"; + const isWriteRoute = isWriteRateLimitedRoute(req.method, url.pathname); const allowed = checkRateLimit( req, url.pathname, diff --git a/packages/hyperbet-evm/keeper/src/service.ts b/packages/hyperbet-evm/keeper/src/service.ts index 606dc08a..3ba9212a 100644 --- a/packages/hyperbet-evm/keeper/src/service.ts +++ b/packages/hyperbet-evm/keeper/src/service.ts @@ -23,7 +23,11 @@ import { type VerifiedExternalBetRecord, } from "@hyperbet/evm-keeper-core"; import { + findUnsupportedJsonRpcMethod, + isWriteRateLimitedRoute, mergePredictionMarketsWithHealth, + PUBLIC_EVM_RPC_READ_METHODS, + PUBLIC_SOLANA_RPC_READ_METHODS, type KeeperBotHealthSnapshot, type KeeperMarketHealthRecord, } from "@hyperbet/mm-core"; @@ -3312,6 +3316,19 @@ async function handleSolanaRpcProxy(req: Request): Promise { if (!rpcBody.ok) { return rpcBody.response; } + const unsupportedMethod = findUnsupportedJsonRpcMethod( + rpcBody.requests, + PUBLIC_SOLANA_RPC_READ_METHODS, + ); + if (unsupportedMethod) { + return jsonResponse( + req, + { + error: `JSON-RPC method ${unsupportedMethod} is not allowed on the public Solana RPC proxy`, + }, + 403, + ); + } try { const ttlMs = resolveJsonRpcCacheTtlMs( @@ -3441,6 +3458,19 @@ async function handleEvmRpcProxy(req: Request, url: URL): Promise { if (!rpcBody.ok) { return rpcBody.response; } + const unsupportedMethod = findUnsupportedJsonRpcMethod( + rpcBody.requests, + PUBLIC_EVM_RPC_READ_METHODS, + ); + if (unsupportedMethod) { + return jsonResponse( + req, + { + error: `JSON-RPC method ${unsupportedMethod} is not allowed on the public EVM RPC proxy`, + }, + 403, + ); + } try { const ttlMs = resolveJsonRpcCacheTtlMs( @@ -3603,8 +3633,7 @@ const server = Bun.serve({ development: process.env.NODE_ENV !== "production", fetch: async (req: Request) => { const url = new URL(req.url); - const isWriteRoute = - req.method === "POST" || url.pathname === "/api/streaming/state/publish"; + const isWriteRoute = isWriteRateLimitedRoute(req.method, url.pathname); const allowed = checkRateLimit( req, url.pathname, diff --git a/packages/hyperbet-mm-core/src/index.ts b/packages/hyperbet-mm-core/src/index.ts index 1cd66408..29b431fe 100644 --- a/packages/hyperbet-mm-core/src/index.ts +++ b/packages/hyperbet-mm-core/src/index.ts @@ -840,3 +840,5 @@ export function buildAmmTradeDecision( reason: `deviation-${deviationBps}bps`, }; } + +export * from "./rpcProxyPolicy.js"; diff --git a/packages/hyperbet-mm-core/src/rpcProxyPolicy.test.ts b/packages/hyperbet-mm-core/src/rpcProxyPolicy.test.ts new file mode 100644 index 00000000..9057b29e --- /dev/null +++ b/packages/hyperbet-mm-core/src/rpcProxyPolicy.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; + +import { + findUnsupportedJsonRpcMethod, + isWriteRateLimitedRoute, + PUBLIC_EVM_RPC_READ_METHODS, + PUBLIC_SOLANA_RPC_READ_METHODS, +} from "./rpcProxyPolicy.js"; + +describe("rpc proxy policy", () => { + test("treats public RPC proxy POSTs as read traffic", () => { + expect(isWriteRateLimitedRoute("POST", "/api/proxy/solana/rpc")).toBe( + false, + ); + expect(isWriteRateLimitedRoute("POST", "/api/proxy/evm/rpc")).toBe(false); + expect(isWriteRateLimitedRoute("GET", "/api/perps/markets")).toBe(false); + }); + + test("keeps write and unknown POST routes on the write bucket", () => { + expect( + isWriteRateLimitedRoute("POST", "/api/streaming/state/publish"), + ).toBe(true); + expect( + isWriteRateLimitedRoute("POST", "/api/arena/invite/redeem"), + ).toBe(true); + expect( + isWriteRateLimitedRoute("POST", "/api/proxy/solana/sender"), + ).toBe(true); + expect(isWriteRateLimitedRoute("POST", "/api/unknown")).toBe(true); + }); + + test("allows the public EVM read subset and rejects send methods", () => { + expect( + findUnsupportedJsonRpcMethod( + [ + { method: "eth_chainId" }, + { method: "eth_getTransactionCount" }, + { method: "eth_estimateGas" }, + { method: "eth_gasPrice" }, + ], + PUBLIC_EVM_RPC_READ_METHODS, + ), + ).toBeNull(); + + expect( + findUnsupportedJsonRpcMethod( + [{ method: "eth_sendRawTransaction" }], + PUBLIC_EVM_RPC_READ_METHODS, + ), + ).toBe("eth_sendRawTransaction"); + }); + + test("allows Solana read/confirm methods and rejects sendTransaction", () => { + expect( + findUnsupportedJsonRpcMethod( + [ + { method: "getLatestBlockhash" }, + { method: "getSignatureStatuses" }, + { method: "getTokenAccountsByOwner" }, + ], + PUBLIC_SOLANA_RPC_READ_METHODS, + ), + ).toBeNull(); + + expect( + findUnsupportedJsonRpcMethod( + [{ method: "sendTransaction" }], + PUBLIC_SOLANA_RPC_READ_METHODS, + ), + ).toBe("sendTransaction"); + }); +}); diff --git a/packages/hyperbet-mm-core/src/rpcProxyPolicy.ts b/packages/hyperbet-mm-core/src/rpcProxyPolicy.ts new file mode 100644 index 00000000..16f24a45 --- /dev/null +++ b/packages/hyperbet-mm-core/src/rpcProxyPolicy.ts @@ -0,0 +1,106 @@ +export type JsonRpcMethodRequest = { + method: string; +}; + +type AllowedJsonRpcMethods = + | Readonly> + | ReadonlySet + | readonly string[]; + +const READ_RATE_LIMIT_POST_PATHS = new Set([ + "/api/proxy/solana/rpc", + "/api/proxy/evm/rpc", +]); + +const WRITE_RATE_LIMIT_POST_PATHS = new Set([ + "/api/streaming/state/publish", + "/api/arena/bet/record-external", + "/api/arena/invite/redeem", + "/api/arena/wallet-link", + "/api/proxy/solana/sender", +]); + +export const PUBLIC_EVM_RPC_READ_METHODS = new Set([ + "eth_blockNumber", + "eth_call", + "eth_chainId", + "eth_estimateGas", + "eth_gasPrice", + "eth_getBalance", + "eth_getBlockByNumber", + "eth_getCode", + "eth_getLogs", + "eth_getStorageAt", + "eth_getTransactionByHash", + "eth_getTransactionCount", + "eth_getTransactionReceipt", + "net_version", + "web3_clientVersion", +]); + +export const PUBLIC_SOLANA_RPC_READ_METHODS = new Set([ + "getAccountInfo", + "getBalance", + "getBlockHeight", + "getBlockTime", + "getEpochInfo", + "getEpochSchedule", + "getFeeForMessage", + "getGenesisHash", + "getHealth", + "getIdentity", + "getLatestBlockhash", + "getMinimumBalanceForRentExemption", + "getMultipleAccounts", + "getProgramAccounts", + "getRecentPrioritizationFees", + "getSignatureStatuses", + "getSlot", + "getSupply", + "getTokenAccountBalance", + "getTokenAccountsByOwner", + "getTokenLargestAccounts", + "getTokenSupply", + "getVersion", +]); + +function isAllowedJsonRpcMethod( + method: string, + allowedMethods: AllowedJsonRpcMethods, +): boolean { + if (allowedMethods instanceof Set) { + return allowedMethods.has(method); + } + if (Array.isArray(allowedMethods)) { + return allowedMethods.includes(method); + } + return Object.hasOwn(allowedMethods, method); +} + +export function findUnsupportedJsonRpcMethod( + requests: readonly JsonRpcMethodRequest[], + allowedMethods: AllowedJsonRpcMethods, +): string | null { + for (const request of requests) { + if (!isAllowedJsonRpcMethod(request.method, allowedMethods)) { + return request.method; + } + } + return null; +} + +export function isWriteRateLimitedRoute( + method: string, + pathname: string, +): boolean { + if (method.toUpperCase() !== "POST") { + return false; + } + if (READ_RATE_LIMIT_POST_PATHS.has(pathname)) { + return false; + } + if (WRITE_RATE_LIMIT_POST_PATHS.has(pathname)) { + return true; + } + return true; +} diff --git a/packages/hyperbet-solana/keeper/src/service.ts b/packages/hyperbet-solana/keeper/src/service.ts index c7ed0aa4..768af283 100644 --- a/packages/hyperbet-solana/keeper/src/service.ts +++ b/packages/hyperbet-solana/keeper/src/service.ts @@ -16,7 +16,10 @@ import { type RecordedBetChain, } from "@hyperbet/chain-registry"; import { + findUnsupportedJsonRpcMethod, + isWriteRateLimitedRoute, mergePredictionMarketsWithHealth, + PUBLIC_SOLANA_RPC_READ_METHODS, type KeeperBotHealthSnapshot, type KeeperMarketHealthRecord, } from "@hyperbet/mm-core"; @@ -135,6 +138,10 @@ type RateBucket = { lastRefillMs: number; }; +type JsonRpcRequestPayload = Record & { + method: string; +}; + const encoder = new TextEncoder(); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const keeperRoot = path.resolve(__dirname, ".."); @@ -2592,6 +2599,19 @@ async function handleSolanaRpcProxy(req: Request): Promise { if (!rpcBody.ok) { return rpcBody.response; } + const unsupportedMethod = findUnsupportedJsonRpcMethod( + rpcBody.requests, + PUBLIC_SOLANA_RPC_READ_METHODS, + ); + if (unsupportedMethod) { + return jsonResponse( + req, + { + error: `JSON-RPC method ${unsupportedMethod} is not allowed on the public Solana RPC proxy`, + }, + 403, + ); + } try { const upstream = await fetch(SOLANA_RPC_PROXY_URL, { @@ -2730,7 +2750,7 @@ async function handleSolanaSenderProxy(req: Request): Promise { } type JsonRpcBodyResult = - | { ok: true; bodyText: string } + | { ok: true; bodyText: string; requests: JsonRpcRequestPayload[] } | { ok: false; response: Response }; async function readJsonRpcBody( @@ -2775,7 +2795,9 @@ async function readJsonRpcBody( }; } - const requests = Array.isArray(parsedBody) ? parsedBody : [parsedBody]; + const requests = ( + Array.isArray(parsedBody) ? parsedBody : [parsedBody] + ) as JsonRpcRequestPayload[]; const hasInvalidRequest = requests.some((entry) => { if (!entry || typeof entry !== "object") return true; const method = (entry as Record).method; @@ -2788,7 +2810,14 @@ async function readJsonRpcBody( }; } - return { ok: true, bodyText }; + return { + ok: true, + bodyText, + requests: requests.map((entry) => ({ + ...entry, + method: entry.method.trim(), + })), + }; } async function handleBirdeyePrice(req: Request, url: URL): Promise { @@ -2904,8 +2933,7 @@ const server = Bun.serve({ development: process.env.NODE_ENV !== "production", fetch: async (req: Request) => { const url = new URL(req.url); - const isWriteRoute = - req.method === "POST" || url.pathname === "/api/streaming/state/publish"; + const isWriteRoute = isWriteRateLimitedRoute(req.method, url.pathname); const allowed = checkRateLimit( req, url.pathname, diff --git a/packages/hyperbet-ui/src/lib/chainConfig.ts b/packages/hyperbet-ui/src/lib/chainConfig.ts index 71ee46d8..e9576152 100644 --- a/packages/hyperbet-ui/src/lib/chainConfig.ts +++ b/packages/hyperbet-ui/src/lib/chainConfig.ts @@ -5,7 +5,7 @@ import { } from "@hyperbet/chain-registry"; import type { Chain } from "wagmi/chains"; -import { CONFIG, getEvmRpcUrl } from "./config"; +import { CONFIG, getEvmReadRpcUrl, getEvmSubmitRpcUrl } from "./config"; export type ChainId = "solana" | BettingEvmChain; @@ -15,6 +15,8 @@ export type EvmChainConfig = { name: string; shortName: string; rpcUrl: string; + readRpcUrl: string; + submitRpcUrl: string; goldClobAddress: string; nativeCurrency: { name: string; symbol: string; decimals: number }; blockExplorer: string; @@ -87,24 +89,28 @@ function getRuntimeChainConfig(chainKey: BettingEvmChain): EvmChainConfig | null const blockExplorer = runtime.chainId === runtime.deployment.chainId ? runtime.deployment.blockExplorerUrl - : getEvmRpcUrl(chainKey); + : getEvmReadRpcUrl(chainKey); const isTestnet = runtime.deployment.targetKind === "testnet" || runtime.chainId !== runtime.deployment.chainId; + const readRpcUrl = getEvmReadRpcUrl(chainKey); + const submitRpcUrl = getEvmSubmitRpcUrl(chainKey); return { chainId: chainKey, evmChainId: runtime.chainId, name, shortName, - rpcUrl: getEvmRpcUrl(chainKey), + rpcUrl: readRpcUrl, + readRpcUrl, + submitRpcUrl, goldClobAddress: runtime.goldClobAddress, nativeCurrency: runtime.deployment.nativeCurrency, blockExplorer, wagmiChain: createCustomChain({ chainId: runtime.chainId, name, - rpcUrl: getEvmRpcUrl(chainKey), + rpcUrl: readRpcUrl, blockExplorer, nativeCurrency: runtime.deployment.nativeCurrency, testnet: isTestnet, diff --git a/packages/hyperbet-ui/src/lib/config.ts b/packages/hyperbet-ui/src/lib/config.ts index 81706f3d..23012b7b 100644 --- a/packages/hyperbet-ui/src/lib/config.ts +++ b/packages/hyperbet-ui/src/lib/config.ts @@ -702,27 +702,39 @@ function resolvedEvmNetworkKey( return chainId === 43114 ? "avax" : "avaxFuji"; } -export function getEvmRpcUrl(chain: BettingEvmChain): string { - if (shouldUseGameEvmRpcProxy()) { - return `${GAME_API_URL}/api/proxy/evm/rpc?chain=${encodeURIComponent(chain)}`; - } +function getDirectEvmRpcUrl(chain: BettingEvmChain): string { return CONFIG.evmChains[chain]?.rpcUrl ?? defaultRpcUrlForEvmNetwork( resolveBettingEvmDefaults(asDeploymentEnvironment(RUNTIME_ENV))[chain] .networkKey, ); } -export const BSC_RPC_URL: string = getEvmRpcUrl("bsc"); +export function getEvmReadRpcUrl(chain: BettingEvmChain): string { + if (shouldUseGameEvmRpcProxy()) { + return `${GAME_API_URL}/api/proxy/evm/rpc?chain=${encodeURIComponent(chain)}`; + } + return getDirectEvmRpcUrl(chain); +} + +export function getEvmSubmitRpcUrl(chain: BettingEvmChain): string { + return getDirectEvmRpcUrl(chain); +} + +export function getEvmRpcUrl(chain: BettingEvmChain): string { + return getEvmReadRpcUrl(chain); +} + +export const BSC_RPC_URL: string = getEvmReadRpcUrl("bsc"); export const BSC_CHAIN_ID: number = CONFIG.bscChainId; export const BSC_GOLD_CLOB_ADDRESS: string = CONFIG.bscGoldClobAddress; export const BSC_GOLD_TOKEN_ADDRESS: string = CONFIG.bscGoldTokenAddress; -export const BASE_RPC_URL: string = getEvmRpcUrl("base"); +export const BASE_RPC_URL: string = getEvmReadRpcUrl("base"); export const BASE_CHAIN_ID: number = CONFIG.baseChainId; export const BASE_GOLD_CLOB_ADDRESS: string = CONFIG.baseGoldClobAddress; export const BASE_GOLD_TOKEN_ADDRESS: string = CONFIG.baseGoldTokenAddress; -export const AVAX_RPC_URL: string = getEvmRpcUrl("avax"); +export const AVAX_RPC_URL: string = getEvmReadRpcUrl("avax"); export const AVAX_CHAIN_ID: number = CONFIG.avaxChainId; export const AVAX_GOLD_CLOB_ADDRESS: string = CONFIG.avaxGoldClobAddress; export const AVAX_GOLD_TOKEN_ADDRESS: string = CONFIG.avaxGoldTokenAddress; diff --git a/packages/hyperbet-ui/src/lib/evmClient.ts b/packages/hyperbet-ui/src/lib/evmClient.ts index 99ebccec..c7be72f2 100644 --- a/packages/hyperbet-ui/src/lib/evmClient.ts +++ b/packages/hyperbet-ui/src/lib/evmClient.ts @@ -2,16 +2,21 @@ import { createPublicClient, createWalletClient, custom, + encodeAbiParameters, encodeFunctionData, + fallback, http, + keccak256, parseAbiItem, toHex, + webSocket, type Address, type Hash, type Hex, type PublicClient, type WalletClient, } from "viem"; +import type { Account, LocalAccount } from "viem/accounts"; import type { EvmChainConfig } from "./chainConfig"; import { GOLD_CLOB_ABI } from "./goldClobAbi"; @@ -69,6 +74,13 @@ export type ContractWriteClient = { writeContract: WalletClient["writeContract"]; }; +export type ContractWriteAccount = Address | Account; + +type JsonRpcPayload = { + result?: T; + error?: { message?: string }; +}; + export const SIDE_ENUM = { NONE: 0, A: 1, @@ -117,12 +129,39 @@ export function toDuelKeyHex(duelKeyHex: string): Hex { return `0x${normalized}`; } +function deriveAlchemyWsUrl(rpcUrl: string): string | null { + try { + const parsed = new URL(rpcUrl); + if (!/\.alchemy\.com$/i.test(parsed.hostname)) { + return null; + } + parsed.protocol = parsed.protocol === "https:" ? "wss:" : "ws:"; + return parsed.toString(); + } catch { + return null; + } +} + +function createEvmTransport(rpcUrl: string) { + const httpTransport = http(rpcUrl, { + retryCount: 5, + retryDelay: 250, + timeout: 20_000, + }); + const wsUrl = deriveAlchemyWsUrl(rpcUrl); + if (!wsUrl) { + return httpTransport; + } + return fallback([webSocket(wsUrl), httpTransport], { rank: false }); +} + export function createEvmPublicClient( chainConfig: EvmChainConfig, ): PublicClient { return createPublicClient({ chain: chainConfig.wagmiChain, - transport: http(chainConfig.rpcUrl), + ccipRead: false, + transport: createEvmTransport(chainConfig.readRpcUrl), }); } @@ -153,13 +192,17 @@ export function createUnlockedRpcWalletClient( async writeContract(parameters) { const { address, abi, functionName, args, value } = parameters; // viem's encodeFunctionData generics lose type info when parameters are - // destructured from writeContract — inputs are already caller-validated. - const data = (encodeFunctionData as (params: { abi: typeof abi; functionName: string; args: readonly unknown[] }) => Hex)({ + // destructured from writeContract; inputs are already caller-validated. + const data = (encodeFunctionData as (params: { + abi: typeof abi; + functionName: string; + args: readonly unknown[]; + }) => Hex)({ abi, functionName, args: (args ?? []) as readonly unknown[], }); - const response = await fetch(chainConfig.rpcUrl, { + const response = await fetch(chainConfig.submitRpcUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -188,18 +231,122 @@ export function createUnlockedRpcWalletClient( }; } +async function callEvmRpc( + rpcUrl: string, + method: string, + params: readonly unknown[], +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15_000); + try { + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: Date.now(), + method, + params, + }), + signal: controller.signal, + }); + const payload = (await response.json()) as JsonRpcPayload; + if (!response.ok || payload.result === undefined) { + throw new Error(payload.error?.message || `${method} failed`); + } + return payload.result; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`${method} timed out after 15000ms`); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} + +export function createSignedRpcWalletClient( + chainConfig: EvmChainConfig, + account: LocalAccount, +): ContractWriteClient { + return { + chain: chainConfig.wagmiChain, + async writeContract(parameters) { + const { address, abi, functionName, args, value, account: requestAccount } = + parameters; + if ( + requestAccount && + typeof requestAccount !== "string" && + requestAccount.address.toLowerCase() !== account.address.toLowerCase() + ) { + throw new Error("headless signer account mismatch"); + } + if ( + typeof requestAccount === "string" && + requestAccount.toLowerCase() !== account.address.toLowerCase() + ) { + throw new Error("headless signer address mismatch"); + } + + const castArgs = (Array.isArray(args) ? args : []) as readonly unknown[]; + const data = encodeFunctionData( + { + abi, + functionName, + args: castArgs, + } as unknown as Parameters[0], + ); + const from = account.address; + const txRequest = { + from, + to: address, + data, + ...(value !== undefined ? { value: toHex(value) } : {}), + }; + const [nonceHex, gasPriceHex, gasEstimateHex] = await Promise.all([ + callEvmRpc(chainConfig.submitRpcUrl, "eth_getTransactionCount", [ + from, + "pending", + ]), + callEvmRpc(chainConfig.submitRpcUrl, "eth_gasPrice", []), + callEvmRpc(chainConfig.submitRpcUrl, "eth_estimateGas", [ + txRequest, + ]), + ]); + const serialized = await account.signTransaction({ + chainId: chainConfig.evmChainId, + data, + gas: (BigInt(gasEstimateHex) * 12n) / 10n, + gasPrice: BigInt(gasPriceHex), + nonce: Number(BigInt(nonceHex)), + to: address, + type: "legacy", + value: value ?? 0n, + }); + return callEvmRpc( + chainConfig.submitRpcUrl, + "eth_sendRawTransaction", + [serialized], + ); + }, + }; +} + export async function marketKeyForDuel( - client: PublicClient, - contractAddress: Address, + _client: PublicClient, + _contractAddress: Address, duelKey: Hex, marketKind: number, ): Promise { - return client.readContract({ - address: contractAddress, - abi: GOLD_CLOB_ABI, - functionName: "marketKey", - args: [duelKey, marketKind], - }) as Promise; + return keccak256( + encodeAbiParameters( + [ + { name: "duelKey", type: "bytes32" }, + { name: "marketKind", type: "uint8" }, + ], + [duelKey, marketKind], + ), + ); } export async function getMarketMeta( @@ -465,7 +612,7 @@ export async function placeOrder( price: number, amount: bigint, orderFlags: number, - account: Address, + account: ContractWriteAccount, value: bigint, ): Promise { return walletClient.writeContract({ @@ -485,7 +632,7 @@ export async function cancelOrder( duelKey: Hex, marketKind: number, orderId: bigint, - account: Address, + account: ContractWriteAccount, ): Promise { return walletClient.writeContract({ address: contractAddress, @@ -502,7 +649,7 @@ export async function claimWinnings( contractAddress: Address, duelKey: Hex, marketKind: number, - account: Address, + account: ContractWriteAccount, ): Promise { return walletClient.writeContract({ address: contractAddress, @@ -519,7 +666,7 @@ export async function syncMarketFromOracle( contractAddress: Address, duelKey: Hex, marketKind: number, - account: Address, + account: ContractWriteAccount, ): Promise { return walletClient.writeContract({ address: contractAddress, diff --git a/packages/hyperbet-ui/src/lib/jupiter.ts b/packages/hyperbet-ui/src/lib/jupiter.ts index ece7f94e..c89c85ac 100644 --- a/packages/hyperbet-ui/src/lib/jupiter.ts +++ b/packages/hyperbet-ui/src/lib/jupiter.ts @@ -2,6 +2,8 @@ import { CONFIG } from "./config"; import { WalletContextState } from "@solana/wallet-adapter-react"; import { Connection, VersionedTransaction } from "@solana/web3.js"; +import { confirmSignatureViaRpc, sendRawTransactionViaRpc } from "./solanaRpc"; + const DEFAULT_JUPITER_BASE_URL = CONFIG.jupiterBaseUrl; type QuoteResponse = { @@ -70,12 +72,7 @@ export async function swapToGoldViaJupiter(params: { ); const signed = await wallet.signTransaction(tx); - const signature = await connection.sendRawTransaction(signed.serialize(), { - maxRetries: 3, - skipPreflight: false, - preflightCommitment: "confirmed", - }); - - await connection.confirmTransaction(signature, "confirmed"); + const signature = await sendRawTransactionViaRpc(connection, signed); + await confirmSignatureViaRpc(connection, signature); return signature; } diff --git a/packages/hyperbet-ui/src/lib/solanaRpc.ts b/packages/hyperbet-ui/src/lib/solanaRpc.ts index ddf1f605..3e823b7c 100644 --- a/packages/hyperbet-ui/src/lib/solanaRpc.ts +++ b/packages/hyperbet-ui/src/lib/solanaRpc.ts @@ -86,6 +86,29 @@ function resolveRpcEndpoint(endpoint: string): string { return endpoint; } +function isKeeperSolanaReadProxyEndpoint(endpoint: string): boolean { + try { + return new URL(resolveRpcEndpoint(endpoint)).pathname === "/api/proxy/solana/rpc"; + } catch { + return false; + } +} + +function resolveSolanaSenderEndpoint(endpoint: string): string { + const resolvedEndpoint = resolveRpcEndpoint(endpoint); + try { + const parsed = new URL(resolvedEndpoint); + if (parsed.pathname === "/api/proxy/solana/rpc") { + parsed.pathname = "/api/proxy/solana/sender"; + parsed.search = ""; + return parsed.toString(); + } + } catch { + // Fall through to the resolved endpoint. + } + return resolvedEndpoint; +} + async function callJsonRpc( endpoint: string, method: string, @@ -121,6 +144,31 @@ async function callJsonRpc( return payload.result; } +async function sendTransactionViaProxySender( + endpoint: string, + wireTransactionBase64: string, +): Promise { + const response = await fetch(resolveSolanaSenderEndpoint(endpoint), { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + transaction: wireTransactionBase64, + }), + }); + + const payload = (await response.json()) as + | { signature: string } + | { error?: string }; + if (!response.ok || !("signature" in payload)) { + throw new Error( + ("error" in payload && payload.error) || `sendTransaction HTTP ${response.status}`, + ); + } + return payload.signature; +} + export async function getLatestBlockhashViaRpc( connection: Connection, ): Promise<{ @@ -144,6 +192,13 @@ export async function sendRawTransactionViaRpc( transaction: Transaction | VersionedTransaction, ): Promise { const serialized = transaction.serialize(); + const wireTransactionBase64 = Buffer.from(serialized).toString("base64"); + if (isKeeperSolanaReadProxyEndpoint(connection.rpcEndpoint)) { + return sendTransactionViaProxySender( + connection.rpcEndpoint, + wireTransactionBase64, + ); + } try { return await connection.sendRawTransaction(serialized, { preflightCommitment: "confirmed", @@ -152,7 +207,7 @@ export async function sendRawTransactionViaRpc( }); } catch { return callJsonRpc(connection.rpcEndpoint, "sendTransaction", [ - Buffer.from(serialized).toString("base64"), + wireTransactionBase64, { encoding: "base64", preflightCommitment: "confirmed", diff --git a/packages/hyperbet-ui/src/lib/token.ts b/packages/hyperbet-ui/src/lib/token.ts index daecd637..5d7b06e1 100644 --- a/packages/hyperbet-ui/src/lib/token.ts +++ b/packages/hyperbet-ui/src/lib/token.ts @@ -5,6 +5,8 @@ import { } from "@solana/spl-token"; import { Connection, PublicKey, Transaction } from "@solana/web3.js"; +import { confirmSignatureViaRpc, sendRawTransactionViaRpc } from "./solanaRpc"; + function isMintLookupError(error: unknown): boolean { const message = (error as Error)?.message?.toLowerCase?.() ?? ""; return message.includes("could not find mint"); @@ -92,25 +94,14 @@ export async function confirmTx( connection: Connection, signature: string, ): Promise { - const latest = await connection.getLatestBlockhash("confirmed"); - await connection.confirmTransaction( - { - signature, - blockhash: latest.blockhash, - lastValidBlockHeight: latest.lastValidBlockHeight, - }, - "confirmed", - ); + await confirmSignatureViaRpc(connection, signature); } export async function sendTx( connection: Connection, signedTx: Transaction, ): Promise { - const signature = await connection.sendRawTransaction(signedTx.serialize(), { - skipPreflight: false, - preflightCommitment: "confirmed", - }); + const signature = await sendRawTransactionViaRpc(connection, signedTx); await confirmTx(connection, signature); return signature; } diff --git a/packages/hyperbet-ui/src/lib/wagmiConfig.ts b/packages/hyperbet-ui/src/lib/wagmiConfig.ts index 83854424..1670c03c 100644 --- a/packages/hyperbet-ui/src/lib/wagmiConfig.ts +++ b/packages/hyperbet-ui/src/lib/wagmiConfig.ts @@ -12,12 +12,12 @@ import { CONFIG } from "./config"; const chains = getWagmiChains(); const enabledEvmChains = getEnabledEvmChains(); const fallbackRpcUrl = - enabledEvmChains[0]?.rpcUrl ?? chains[0]?.rpcUrls.default.http[0] ?? ""; + enabledEvmChains[0]?.readRpcUrl ?? chains[0]?.rpcUrls.default.http[0] ?? ""; // Build transport map from enabled chains const transports: Record> = {}; for (const evmChain of enabledEvmChains) { - transports[evmChain.evmChainId] = http(evmChain.rpcUrl); + transports[evmChain.evmChainId] = http(evmChain.readRpcUrl); } // Fallback for any chain that didn't get explicitly mapped for (const chain of chains) { diff --git a/packages/hyperbet-ui/tests/solanaRpc.test.ts b/packages/hyperbet-ui/tests/solanaRpc.test.ts new file mode 100644 index 00000000..bfea8e96 --- /dev/null +++ b/packages/hyperbet-ui/tests/solanaRpc.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; + +import { sendRawTransactionViaRpc } from "../src/lib/solanaRpc"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("solanaRpc", () => { + test("routes keeper-backed submit traffic to the sender endpoint", async () => { + const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe( + "https://keeper.example.com/api/proxy/solana/sender", + ); + expect(init?.method).toBe("POST"); + + const payload = JSON.parse(String(init?.body)) as { + transaction: string; + }; + expect(payload.transaction).toBe("AQID"); + + return new Response(JSON.stringify({ signature: "sig-123" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const signature = await sendRawTransactionViaRpc( + { + rpcEndpoint: + "https://keeper.example.com/api/proxy/solana/rpc?cluster=mainnet-beta", + } as any, + { + serialize: () => Uint8Array.from([1, 2, 3]), + } as any, + ); + + expect(signature).toBe("sig-123"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); From 440ce2bf4f77e727479ce678455e079574ba3c06 Mon Sep 17 00:00:00 2001 From: rndrntwrk <180591682+rndrntwrk@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:08:55 -0500 Subject: [PATCH 002/161] docs: align release tracking with active scope --- .../enoomian-evm-standardization-decisions.md | 4 + docs/enoomian-next-phase-gates.md | 4 + docs/enoomian-prediction-market-sprint.md | 4 + docs/hyperbet-production-deploy.md | 126 +- docs/prediction-market-release-prep.md | 284 +-- .../external-audit-package-checklist.md | 112 +- .../github-project-production-backlog.md | 1650 ++++++++++++++++ docs/release/launch-ops-evidence-index.md | 98 +- .../localnet-headless-validation-tracker.md | 94 + docs/release/pm-launch-execution-plan.md | 59 +- ...prediction-market-launch-freeze-tracker.md | 328 ++-- .../production-readiness-audit-2026-03-29.md | 453 +++++ docs/release/release-memo-template.md | 103 +- .../runtime-integration-readiness-matrix.md | 59 + .../stage-a-browser-acceptance-matrix.md | 192 ++ .../stage-a-promotion-execution-ledger.md | 1743 +++++++++++++++++ docs/release/testnet-operations-ledger.md | 241 +-- docs/release/tracking-document-map.md | 94 + docs/runbooks/create2-mainnet-deploy.md | 177 +- .../hyperscapes-local-pm-integration.md | 108 +- 20 files changed, 5124 insertions(+), 809 deletions(-) create mode 100644 docs/release/github-project-production-backlog.md create mode 100644 docs/release/localnet-headless-validation-tracker.md create mode 100644 docs/release/production-readiness-audit-2026-03-29.md create mode 100644 docs/release/runtime-integration-readiness-matrix.md create mode 100644 docs/release/stage-a-browser-acceptance-matrix.md create mode 100644 docs/release/stage-a-promotion-execution-ledger.md create mode 100644 docs/release/tracking-document-map.md diff --git a/docs/enoomian-evm-standardization-decisions.md b/docs/enoomian-evm-standardization-decisions.md index 1b843fd4..eb25dbe4 100644 --- a/docs/enoomian-evm-standardization-decisions.md +++ b/docs/enoomian-evm-standardization-decisions.md @@ -1,5 +1,9 @@ # Enoomian EVM Standardization Decisions +> **TL;DR:** Historical decision log for the `enoomian/prediction-market-sprint-base` EVM standardization work. Status: closed. Useful as architecture history, but not the current branch or launch authority. Current release truth lives in the launch execution plan, freeze tracker, and chain registry. + +> Current open-work ownership now lives in [`docs/release/tracking-document-map.md`](release/tracking-document-map.md) and [`docs/release/github-project-production-backlog.md`](release/github-project-production-backlog.md). + This document records the authoritative keep/adapt/reject decisions for the `hyperbet-evm-parity-sweep` assimilation work as it has been standardized onto the local `enoomian/prediction-market-sprint-base`. diff --git a/docs/enoomian-next-phase-gates.md b/docs/enoomian-next-phase-gates.md index 9640000c..9b5e82a4 100644 --- a/docs/enoomian-next-phase-gates.md +++ b/docs/enoomian-next-phase-gates.md @@ -1,5 +1,9 @@ # Enoomian Next-Phase Gates +> **TL;DR:** Historical planning artifact from the `enoomian/prediction-market-sprint-base` multi-branch gate model. Status: closed. Do not use this as the current launch execution plan. The current source of truth is [`docs/release/pm-launch-execution-plan.md`](release/pm-launch-execution-plan.md) plus [`docs/release/prediction-market-launch-freeze-tracker.md`](release/prediction-market-launch-freeze-tracker.md). + +> Current open-work ownership now lives in [`docs/release/tracking-document-map.md`](release/tracking-document-map.md) and [`docs/release/github-project-production-backlog.md`](release/github-project-production-backlog.md). + This document defines the post-sprint launch-completion work for `enoomian/prediction-market-sprint-base`. diff --git a/docs/enoomian-prediction-market-sprint.md b/docs/enoomian-prediction-market-sprint.md index b08c44a5..3fbea2eb 100644 --- a/docs/enoomian-prediction-market-sprint.md +++ b/docs/enoomian-prediction-market-sprint.md @@ -1,5 +1,9 @@ # Enoomian Prediction Market Sprint +> **TL;DR:** Historical sprint tracker for `enoomian/prediction-market-sprint-base`. Status: closed. Keep this as branch history only. It is not the current phase-1 launch closeout plan. Use [`docs/release/pm-launch-execution-plan.md`](release/pm-launch-execution-plan.md) and [`docs/release/prediction-market-launch-freeze-tracker.md`](release/prediction-market-launch-freeze-tracker.md) for current status. + +> Current open-work ownership now lives in [`docs/release/tracking-document-map.md`](release/tracking-document-map.md) and [`docs/release/github-project-production-backlog.md`](release/github-project-production-backlog.md). + This is the living gate tracker for the `enoomian/prediction-market-sprint-base` branch. Update this document every time the sprint base branch is pushed, or when the diff --git a/docs/hyperbet-production-deploy.md b/docs/hyperbet-production-deploy.md index cee2939a..018f0133 100644 --- a/docs/hyperbet-production-deploy.md +++ b/docs/hyperbet-production-deploy.md @@ -1,7 +1,15 @@ # Hyperbet Production Deploy (Cloudflare + Railway) +> **TL;DR:** This is the production and staged topology for the phase-1 launch product. The active production-readiness chains are `Solana` and `BSC`; AVAX remains a preserved but isolated follow-on lane, and `Base` remains a non-blocking add-chain lane. The deployment topology is in place, but as of 2026-03-25 real staged proof and soak remain blocked because GitHub does not yet have a provisioned `staging` environment or the required `HYPERBET_*_STAGING_*` vars and secrets. This topology also serves the wallet/account and rewards APIs, so their durability claims are only as strong as the configured keeper persistence and reconciliation model. + This is the recommended production topology for the Hyperbet stack in this repo. +Detailed implementation work is tracked in: + +- [GitHub Project Production Backlog](release/github-project-production-backlog.md) +- [Runtime Integration Readiness Matrix](release/runtime-integration-readiness-matrix.md) +- [Tracking Document Map](release/tracking-document-map.md) + Operator runbooks are in [docs/runbooks/README.md](runbooks/README.md). - Primary frontend (`packages/hyperbet-solana/app`): Cloudflare Pages (`hyperbet.win`) @@ -12,16 +20,24 @@ Operator runbooks are in [docs/runbooks/README.md](runbooks/README.md). - DDoS/WAF/edge cache: Cloudflare proxy in front of the betting API - Contracts/state: Solana + EVM (configured by env vars below, proxied server-side) -AVAX now has repo-backed Pages and keeper deployment workflows, but production rollout is still blocked until canonical AVAX deployment addresses are committed to the shared chain registry, staged proof artifacts are captured for the target environment, and the real AVAX governance/operator wallets are provisioned. +Production rollout is still blocked until canonical active-scope deployment +truth exists in the shared chain registry for `Solana` and `BSC`, staged proof +artifacts are captured for the target environment, and the real governance and +operator wallets are provisioned. AVAX deployment topology remains preserved +for follow-on use but is not a blocker for the current path. + +This document owns the deployment topology and environment contract. It does not +own the execution backlog. ## Staging Rail -The repo also supports a manual staging rail for Solana, BSC, and AVAX without -changing the production topology: +The repo also supports a manual staging rail for Solana and BSC without +changing the production topology. AVAX staging remains preserved as follow-on +scope: - staged Solana Pages + staged Solana keeper - staged BSC Pages + staged BSC keeper -- staged AVAX Pages + staged AVAX keeper +- optional staged AVAX Pages + staged AVAX keeper when the lane is reactivated - external staged duel/stream source Manual staging deploys use the same workflows as production through @@ -31,11 +47,21 @@ Manual staging deploys use the same workflows as production through - `Deploy Hyperbet Solana Keeper` - `Deploy Hyperbet BSC Pages` - `Deploy Hyperbet BSC Keeper` -- `Deploy Hyperbet AVAX Pages` -- `Deploy Hyperbet AVAX Keeper` +- optional when AVAX is reactivated: + - `Deploy Hyperbet AVAX Pages` + - `Deploy Hyperbet AVAX Keeper` Select `environment=staging` when dispatching the relevant workflow. +Current audited GitHub state: + +- repo-level deploy and testnet secrets exist +- no GitHub `staging` environment exists yet +- no `HYPERBET_*_STAGING_*` vars or secrets are provisioned yet + +That means the code path is ready, but staged proof and staged soak remain +operationally blocked until staging is provisioned. + Required staging vars are: - `HYPERBET_SOLANA_PAGES_STAGING_PROJECT_NAME` @@ -52,31 +78,65 @@ Required staging vars are: - `HYPERBET_BSC_RAILWAY_STAGING_PROJECT_ID` - `HYPERBET_BSC_RAILWAY_STAGING_ENVIRONMENT_ID` - `HYPERBET_BSC_RAILWAY_STAGING_KEEPER_SERVICE_ID` -- `HYPERBET_AVAX_PAGES_STAGING_PROJECT_NAME` -- `HYPERBET_AVAX_PAGES_STAGING_URL` -- `HYPERBET_AVAX_KEEPER_STAGING_URL` -- `HYPERBET_AVAX_KEEPER_STAGING_WS_URL` -- `HYPERBET_AVAX_RAILWAY_STAGING_PROJECT_ID` -- `HYPERBET_AVAX_RAILWAY_STAGING_ENVIRONMENT_ID` -- `HYPERBET_AVAX_RAILWAY_STAGING_KEEPER_SERVICE_ID` -- `HYPERBET_AVAX_STAGING_CHAIN_ID` -- `HYPERBET_AVAX_STAGING_GOLD_CLOB_ADDRESS` - -Required AVAX staged proof vars/secrets used by Gate 14A: - -- `HYPERBET_AVAX_STAGING_RPC_URL` -- `HYPERBET_AVAX_STAGING_REPORTER_PRIVATE_KEY` -- `HYPERBET_AVAX_STAGING_CANARY_PRIVATE_KEY` -- `HYPERBET_AVAX_STAGING_DUEL_ORACLE_ADDRESS` -- `HYPERBET_AVAX_STAGING_GOLD_CLOB_ADDRESS` -- `HYPERBET_AVAX_STAGING_STREAM_PUBLISH_KEY` -- `HYPERBET_AVAX_RAILWAY_STAGING_PROJECT_ID` -- `HYPERBET_AVAX_RAILWAY_STAGING_ENVIRONMENT_ID` -- `HYPERBET_AVAX_RAILWAY_STAGING_KEEPER_SERVICE_ID` +- optional follow-on AVAX staged vars when that lane is reactivated: + - `HYPERBET_AVAX_PAGES_STAGING_PROJECT_NAME` + - `HYPERBET_AVAX_PAGES_STAGING_URL` + - `HYPERBET_AVAX_KEEPER_STAGING_URL` + - `HYPERBET_AVAX_KEEPER_STAGING_WS_URL` + - `HYPERBET_AVAX_RAILWAY_STAGING_PROJECT_ID` + - `HYPERBET_AVAX_RAILWAY_STAGING_ENVIRONMENT_ID` + - `HYPERBET_AVAX_RAILWAY_STAGING_KEEPER_SERVICE_ID` + - `HYPERBET_AVAX_STAGING_CHAIN_ID` + - `HYPERBET_AVAX_STAGING_GOLD_CLOB_ADDRESS` + +Required staged proof vars/secrets used by the launch-scope proof rail: + - `HYPERBET_STAGED_PROOF_DUEL_ID` - `HYPERBET_STAGED_PROOF_DUEL_KEY` - -AVAX rollout remains blocked until canonical deployment truth exists in the shared chain registry and the effective AVAX wallet/signer set is in place. The staging/prod rail is present so proof and release packaging can use one consistent contract once those addresses are committed. +- `HYPERBET_SOLANA_STAGING_CLUSTER` +- `HYPERBET_SOLANA_STAGING_RPC_URL` +- `HYPERBET_SOLANA_STAGING_GOLD_CLOB_PROGRAM_ID` +- `HYPERBET_SOLANA_STAGING_GOLD_AMM_PROGRAM_ID` +- `HYPERBET_SOLANA_STAGING_GOLD_PERPS_PROGRAM_ID` +- `HYPERBET_SOLANA_STAGING_STREAM_PUBLISH_KEY` +- `HYPERBET_SOLANA_STAGING_ORACLE_AUTHORITY_KEYPAIR` +- `HYPERBET_SOLANA_STAGING_CANARY_KEYPAIR` +- `HYPERBET_BSC_STAGING_RPC_URL` +- `HYPERBET_BSC_STAGING_REPORTER_PRIVATE_KEY` +- `HYPERBET_BSC_STAGING_CANARY_PRIVATE_KEY` +- `HYPERBET_BSC_STAGING_ADMIN_PRIVATE_KEY` +- `HYPERBET_BSC_STAGING_MARKET_OPERATOR_PRIVATE_KEY` +- `HYPERBET_BSC_STAGING_DUEL_ORACLE_ADDRESS` +- `HYPERBET_BSC_STAGING_GOLD_CLOB_ADDRESS` +- `HYPERBET_BSC_STAGING_GOLD_AMM_ROUTER_ADDRESS` +- `HYPERBET_BSC_STAGING_MUSD_TOKEN_ADDRESS` +- `HYPERBET_BSC_STAGING_GOLD_TOKEN_ADDRESS` +- `HYPERBET_BSC_STAGING_SKILL_ORACLE_ADDRESS` +- `HYPERBET_BSC_STAGING_PERP_ENGINE_ADDRESS` +- `HYPERBET_BSC_STAGING_STREAM_PUBLISH_KEY` +- optional follow-on AVAX proof vars when that lane is reactivated: + - `HYPERBET_AVAX_STAGING_RPC_URL` + - `HYPERBET_AVAX_STAGING_REPORTER_PRIVATE_KEY` + - `HYPERBET_AVAX_STAGING_CANARY_PRIVATE_KEY` + - `HYPERBET_AVAX_STAGING_ADMIN_PRIVATE_KEY` + - `HYPERBET_AVAX_STAGING_MARKET_OPERATOR_PRIVATE_KEY` + - `HYPERBET_AVAX_STAGING_DUEL_ORACLE_ADDRESS` + - `HYPERBET_AVAX_STAGING_GOLD_CLOB_ADDRESS` + - `HYPERBET_AVAX_STAGING_GOLD_AMM_ROUTER_ADDRESS` + - `HYPERBET_AVAX_STAGING_MUSD_TOKEN_ADDRESS` + - `HYPERBET_AVAX_STAGING_GOLD_TOKEN_ADDRESS` + - `HYPERBET_AVAX_STAGING_SKILL_ORACLE_ADDRESS` + - `HYPERBET_AVAX_STAGING_PERP_ENGINE_ADDRESS` + - `HYPERBET_AVAX_STAGING_STREAM_PUBLISH_KEY` + - `HYPERBET_AVAX_RAILWAY_STAGING_PROJECT_ID` + - `HYPERBET_AVAX_RAILWAY_STAGING_ENVIRONMENT_ID` + - `HYPERBET_AVAX_RAILWAY_STAGING_KEEPER_SERVICE_ID` + +AVAX rollout remains preserved as an isolated follow-on lane until canonical +deployment truth exists in the shared chain registry and the effective AVAX +wallet/signer set is in place. The staging/prod rail remains documented so +proof and release packaging can use one consistent contract if that lane is +reactivated later. ## 1) Deploy the keeper to Railway @@ -205,6 +265,7 @@ Use the manual `Staged Live Proof` workflow or the repo wrapper: bun run staged:proof -- --mode=read-only --target=all bun run staged:proof -- --mode=canary-write --target=solana bun run staged:proof -- --mode=canary-write --target=bsc +# optional follow-on lane when AVAX is explicitly reactivated bun run staged:proof -- --mode=canary-write --target=avax ``` @@ -216,9 +277,12 @@ The proof wrapper captures: - `/api/keeper/bot-health` - stream-state and duel-context payloads - Solana and BSC proxy proof -- Solana, BSC, and AVAX canary tx hashes/signatures when `mode=canary-write` +- Solana and BSC canary tx hashes/signatures when `mode=canary-write` +- optional AVAX canary tx hashes/signatures when that lane is explicitly + reactivated - `verify:chains` output -- AVAX staging env-audit output +- optional AVAX staging env-audit output when that lane is explicitly + reactivated This is a manual operator proof rail. It should not be treated as complete until a real staged run passes end to end and the artifacts are reviewed. diff --git a/docs/prediction-market-release-prep.md b/docs/prediction-market-release-prep.md index f66704f8..0fef8fc7 100644 --- a/docs/prediction-market-release-prep.md +++ b/docs/prediction-market-release-prep.md @@ -1,137 +1,157 @@ # Prediction Market Release Prep -This document is the reviewer-facing release summary for the prediction-market -sprint. - -As of March 13, 2026, this is a phase-3 release-prep artifact: - -- release-facing docs and runbooks are linked into a candidate audit package -- AVAX deploy/runtime/proof plumbing is merged into the shared launch rails -- EVM governance and emergency pause controls are implemented and documented -- reviewer inventory, release memo, and audit checklist are assembled -- AVAX Fuji deploy/runtime truth is canonicalized in chain registry, and AVAX - production semantics are already wired into fail-closed env/registry runtime - gates; AVAX mainnet canonical addresses plus staged proof and signer-proof - evidence are still outstanding before launch signoff - -This document does not declare the sprint release-ready for unrestricted real -funds. It is the reviewer handoff for the sprint base after the deploy/proof -rails, governance controls, and audit-package scaffolding have landed. - -## Sprint Summary - -Completed work already merged into the sprint base covers: - -- deterministic EVM and Solana scenario execution, exploit gates, and shared - scenario history through the simulation dashboard -- shared market-maker sizing and refresh policy in `@hyperbet/mm-core` -- keeper health, recovery, and operator status visibility across Solana, BSC, - and AVAX -- runtime parity and validator-backed Solana execution for the external - market-maker bot -- frontend lifecycle and claim-state parity across Solana, BSC, and AVAX -- cross-chain local E2E coverage and CI / ops hardening through Gate 11 -- contract-validation, proof, and security CI promotion through Gate 13 -- AVAX staging/runtime/proof plumbing and governance metadata rails for Gates - 19 and 20 -- release evidence, governance runbooks, and audit-package scaffolding for - Gates 23 and 24 - -Current dependency state: - -- Gate 13: complete as contract/security CI promotion -- Gate 14A: proof rail implemented for Solana, BSC, and AVAX; staged - read-only/canary execution still outstanding -- Gate 19: AVAX production rollout blocked pending canonical AVAX mainnet values, - effective AVAX wallet setup, and reviewed staged proof evidence -- Gate 20: governance surfaces merged; live ownership-transfer evidence still - outstanding -- Gate 23 / 24: reviewer docs and audit-package scaffold merged; final handoff - still depends on live artifacts plus incoming Engineer 1/3/4 evidence - -## Reviewer Artifact Inventory - -Primary documents: - -- [Sprint tracker](enoomian-prediction-market-sprint.md) -- [Five-engineer execution plan](release/five-engineer-execution.md) -- [Engineer instructions](release/engineer-instructions.md) -- [GitHub issue bodies](release/issues/README.md) +> **TL;DR:** As of 2026-03-25, the repo is materially stronger and now carries full-product phase-1 rails for `PM + internal AMM + perps` plus the surrounding app shell and account surfaces that users actually touch. The active production-readiness gate is `Solana devnet + BSC testnet`; AVAX Fuji evidence is preserved but isolated and non-blocking. The remaining blockers are truthful active-scope registry values, staged environment provisioning, shared testnet token/address inputs, app-shell and account-surface closure, governance/evidence closeout, and the frozen audit packet plus external audit/remediation cycle. + +This document is the reviewer-facing release summary for the current launch +closeout train on `audit/develop-pm-hardening`. + +Detailed implementation work is tracked in: + +- [GitHub Project Production Backlog](release/github-project-production-backlog.md) +- [Runtime Integration Readiness Matrix](release/runtime-integration-readiness-matrix.md) +- [Tracking Document Map](release/tracking-document-map.md) + +## Phase-1 Product Definition + +- Active production-readiness chains: `Solana`, `BSC` +- Preserved but isolated follow-on lane: `AVAX` +- Non-blocking add-chain lane: `Base` +- Implementation target: shared `EVM` runtime plus `SVM`, with `BSC` as the + current active EVM proving wrapper +- User-facing surfaces: + - `PM/CLOB duels` + - `perps/models` +- Production-critical internal surface: + - `AMM` as a headless market-maker and liquidity engine, not a retail UI + +## What Is Already Landed + +- PM-core hardening is merged for oracle finality, order semantics, governance + freezes, and protocol guardrails. +- repo artifact policy is enforced in CI, and the previously flagged tracked + Solana deploy artifacts are no longer present in the tracked tree. +- AMM settlement implementation is materially stronger than before, but the + production settlement model still needs an explicit freeze between + oracle-driven and challenge-window paths. +- Solana perps pause remains callable after freeze. +- Solana full-product deploy, init, freeze, and verify paths now include + `lvr_amm`. +- EVM deploy receipts now write canonical registry-shaped PM, AMM, and perps + fields. +- Staged proof is no longer PM-only. The canary surface now emits `pm`, + `perps`, and `amm` results per chain. +- Local Stage-A bring-up, staged proof, soak, AMM gates, and perps gates are + wired into the repo scripts and workflows. +- Base has been demoted to a non-blocking add-chain lane for phase-1 release + gating. + +## What Is Still Blocking + +Detailed execution tickets for every blocker below live in the canonical +backlog. This document is the summary view, not the issue owner. + +### 1. Active launch-chain canonical truth is incomplete + +Strict active-scope canonical truth is still incomplete, even though the +develop-side Stage-A closeout gate now validates the real PR contract for +`Solana devnet + BSC testnet` while AVAX is intentionally isolated: + +- active-scope launch constants still imply `avax` in places that should now + reflect the parked-chain decision +- default feature flags still understate the intended active product surface +- `solana`: `goldAmmMarketProgramId` +- `bsc`: `goldAmmRouterAddress`, `mUsdTokenAddress`, `goldTokenAddress`, + `skillOracleAddress`, `perpEngineAddress` +- `avax`: PM-core plus AMM and perps canonical fields remain preserved + follow-on work and are not blocking the current production-readiness path + +Those values must still come from final mainnet deployment receipts only before +any true launch promotion to `main` or `staging`. + +### 2. Staged proof and staged soak are structurally ready but operationally blocked + +The workflows now expect a real staged environment contract. As of the latest +GitHub secret and variable audit: + +- repo-level testnet deploy primitives exist +- there is no GitHub `staging` environment +- there are no `HYPERBET_*_STAGING_*` vars or secrets provisioned yet + +That means GitHub staged proof and staged soak cannot run honestly yet. + +### 3. Local Stage-A is only partially unblocked + +Local non-mainnet deploy and verification are now feasible, but the active BSC +AMM/perps rehearsal still needs shared token/address inputs: + +- `BSC_TESTNET_MUSD_TOKEN_ADDRESS` +- `BSC_TESTNET_GOLD_TOKEN_ADDRESS` +- optional perps margin token addresses when margin is not the GOLD token + +### 4. Governance and audit evidence are not complete + +The repo-side governance and freeze logic is much closer to launch grade, but +the final package still needs: + +- ownership transfer and freeze transaction evidence +- signer and key-rotation closeout +- staged proof artifact bundles +- soak artifact bundles +- final RC freeze manifest +- external audit findings and remediation output + +### 5. AMM settlement truth, coordinated full-product smoke, and full app-shell closure are not yet closed + +- the EVM AMM still exposes both `settleMarket()` and `settleFromOracle()` +- the Solana AMM settlement path still carries compatibility-oriented optional + account handling that needs either tighter validation or an audit-grade + rationale +- PM has the strongest live evidence today; coordinated staged smoke and + evidence for the full active product destination still need to be frozen +- wallet/account, points/referral, and broader app-shell product claims still + need one frozen acceptance and support contract for the active runtimes + +## Gold Asset Boundary + +Gold is currently tracked as a separate architecture concern, not a phase-1 +launch blocker for PM, AMM, perps, or market-maker audit readiness. + +- the current launch protocols already settle in native assets or designated + collateral tokens +- Solana still carries the meaningful `goldMint` surface +- the long-term `Hyperscapes Gold -> Solana Gold -> future multi-chain Gold` + model still needs a dedicated asset architecture spec + +For the current-state interpretation and the follow-on spec-planning document, +see: + +- [Gold current state](protocol/gold-current-state.md) +- [Gold architecture spec plan](protocol/gold-architecture-spec-plan.md) + +## Current Reviewer Checklist + +Reviewers should verify that the repo now reflects these truths consistently: + +- active production-readiness scope is `Solana + BSC` +- AVAX is preserved but isolated and non-blocking +- `Base` is non-blocking +- `BSC` is the active EVM wrapper, not the exclusive EVM implementation target +- launch destination is `PM + perps + internal AMM`, not PM-only +- active launch scope includes the application shell and account surfaces + around those products, not only the underlying market engines +- stage/testnet proof does not equal mainnet canonical truth +- AMM settlement-model closure and coordinated full-product smoke are still + open blockers +- launch remains blocked on registry truth, staged env provisioning, and audit + evidence + +## Source Documents + +- [Launch execution plan](release/pm-launch-execution-plan.md) +- [Launch freeze tracker](release/prediction-market-launch-freeze-tracker.md) +- [Launch-ops evidence index](release/launch-ops-evidence-index.md) - [Production deploy guide](hyperbet-production-deploy.md) -- [Development setup](development-setup.md) +- [Gold current state](protocol/gold-current-state.md) +- [Gold architecture spec plan](protocol/gold-architecture-spec-plan.md) - [Runbook index](runbooks/README.md) -- [Market-maker bot README](../packages/market-maker-bot/README.md) -- [Launch-ops evidence index](release/launch-ops-evidence-index.md) -- [Release memo template](release/release-memo-template.md) - [External audit package checklist](release/external-audit-package-checklist.md) - -Operational and CI surfaces to spot-check: - -- [Fast CI workflow](../.github/workflows/ci.yml) -- [Prediction-market gate workflow](../.github/workflows/prediction-market-gates.yml) -- [Staged live proof workflow](../.github/workflows/staged-live-proof.yml) -- `scripts/ci-env-audit.ts` -- `scripts/ci-contracts.ts` -- `scripts/staged-live-proof.ts` -- `packages/simulation-dashboard` -- `packages/market-maker-bot` -- `packages/hyperbet-solana/keeper` -- `packages/hyperbet-bsc/keeper` -- `packages/hyperbet-avax/keeper` - -Representative local verification entrypoints already documented elsewhere: - -- `bun run dev:doctor` -- `bun run dev:bootstrap` -- `bun run ci:contracts:fast` -- `bun run ci:gate:base` -- `bun run --cwd packages/market-maker-bot smoke:runtime:solana` -- `bun run --cwd packages/simulation-dashboard scenario suite --fresh` - -## Merge Checklist For `develop` - -- tracked release-facing docs contain no accidental local absolute-path links -- deploy, setup, and runbook wording matches current repo scripts and workflow - names -- AVAX is described accurately as a launch chain whose production rollout is - blocked until canonical registry addresses and staged-proof artifacts exist -- CI wording reflects the real required lanes: - - `Solana Program Build Gate` - - `EVM Contract Validation` - - `EVM Contract Proof Gate` - - `EVM Contract Security Gate` - - `EVM Exploit Gate` - - `Solana Exploit Gate` - - `Base Add-Chain Smoke` -- Gate 14A is described as having a proof rail but not yet complete until a - real staged run succeeds -- governance, signer-policy, and emergency runbooks are linked from the - release-facing package -- targeted checks and broader regression for the dependency gates are green -- sprint tracker is updated after the relevant base-branch push -- ready-to-merge synthesis is written without overstating release readiness - -## Residual Risk And Blocked Follow-Ups - -- AVAX is still not fully canonicalized for production; the deploy/proof rails are - in place, Fuji is canonicalized in-chain, and mainnet still lacks canonical - addresses. -- AVAX mainnet also depends on effective wallet setup for timelock, multisig, - emergency, reporter, finalizer, challenger, market-operator, treasury, and - market-maker roles; without that signer set the lane is intentionally not - deployable. -- AVAX deploy/proof control plane has been aligned for Fuji and staged proof - behavior, but production sign-off still requires reviewed staged read-only + canary - evidence on the target environment. -- Contract/security CI is now wired into the repo workflows, but local desktop - verification can still be constrained by toolchain issues such as Hardhat - compiler download and macOS-specific Foundry crashes. -- Gate 14A staged live proof remains the largest outstanding operator proof - before claiming full audit-style deployment confidence. -- Production ownership-transfer evidence for timelock, multisig, emergency, and - role separation is still outstanding. -- Any release-facing summary that omits the AVAX Fuji/production distinction, - pending AVAX mainnet canonicality, remaining live proof work, or pending - governance receipts would be misleading. -- AVAX Fuji bootstrap smoke should remain documented as an environment sanity - check, not production-canonical completion. diff --git a/docs/release/external-audit-package-checklist.md b/docs/release/external-audit-package-checklist.md index d134da69..19dac1ea 100644 --- a/docs/release/external-audit-package-checklist.md +++ b/docs/release/external-audit-package-checklist.md @@ -1,59 +1,85 @@ # External Audit Package Checklist +> **TL;DR:** The audit package is no longer just PM-core plus EVM notes. It now has to prove the full phase-1 launch product: `PM/CLOB duels + perps/models + internal AMM` plus the surrounding app-shell, wallet/account, and rewards surfaces across the active production-readiness scope of `Solana` and `BSC`, with truthful active-scope registry values, staged proof and soak evidence, governance transfer receipts, and the frozen RC manifest. AVAX evidence remains preserved follow-on material and is non-blocking unless the lane is reactivated. + Use this checklist with the candidate memo in [release-memo-template.md](release-memo-template.md) and the evidence index in [launch-ops-evidence-index.md](launch-ops-evidence-index.md). +Detailed implementation work is tracked in: + +- [GitHub Project Production Backlog](github-project-production-backlog.md) +- [Runtime Integration Readiness Matrix](runtime-integration-readiness-matrix.md) +- [Tracking Document Map](tracking-document-map.md) + +This checklist owns audit-handoff packet completeness only. It does not own the +underlying implementation backlog. + ## Scope And Freeze -- [ ] Freeze manifest regenerated and attached: - [manifests/rc-2026-03-audit-handoff-freeze.json](manifests/rc-2026-03-audit-handoff-freeze.json) - [x] Launch scope documented in [release-memo-template.md](release-memo-template.md) -- [x] Engineer ownership and issue bodies linked in - [issues/README.md](issues/README.md) +- [x] Launch freeze tracker linked in + [prediction-market-launch-freeze-tracker.md](prediction-market-launch-freeze-tracker.md) - [ ] Final RC branch and commit recorded at freeze time +- [ ] Freeze manifest regenerated and attached: + [manifests/rc-2026-03-audit-handoff-freeze.json](manifests/rc-2026-03-audit-handoff-freeze.json) -## Gate 14A: Staged Live Proof +## Non-Mainnet Bring-Up And Proof -- [x] Proof driver and workflow linked: +- [x] Local Stage-A runner linked: + [../../scripts/run-local-stage-a.ts](../../scripts/run-local-stage-a.ts) +- [x] Staged proof driver and workflow linked: [../../scripts/staged-live-proof.ts](../../scripts/staged-live-proof.ts), [../../.github/workflows/staged-live-proof.yml](../../.github/workflows/staged-live-proof.yml) -- [x] Operator runbook linked: - [../runbooks/staged-live-proof.md](../runbooks/staged-live-proof.md) -- [ ] Read-only artifact bundle attached from `.ci-artifacts/staged-live-proof` -- [ ] Canary-write artifact bundle attached from `.ci-artifacts/staged-live-proof` -- [ ] AVAX env-audit and `verify-chains` outputs attached - -## Gate 19: AVAX Canonicalization And Runtime - -- [x] Shared registry schema linked: - [../../packages/hyperbet-chain-registry/src/index.ts](../../packages/hyperbet-chain-registry/src/index.ts) -- [x] Shared and AVAX manifests linked: - [../../packages/hyperbet-deployments/contracts.json](../../packages/hyperbet-deployments/contracts.json), - [../../packages/hyperbet-avax/deployments/contracts.json](../../packages/hyperbet-avax/deployments/contracts.json) -- [x] AVAX deploy workflows linked: - [../../.github/workflows/deploy-avax-pages.yml](../../.github/workflows/deploy-avax-pages.yml), - [../../.github/workflows/deploy-avax-keeper.yml](../../.github/workflows/deploy-avax-keeper.yml) -- [ ] Canonical AVAX mainnet addresses committed from deployment evidence -- [ ] Production AVAX runtime smoke attached - -## Gate 20: Governance And Emergency Controls - -- [x] Oracle governance surface linked: - [../../packages/evm-contracts/contracts/DuelOutcomeOracle.sol](../../packages/evm-contracts/contracts/DuelOutcomeOracle.sol) -- [x] Market pause surface linked: - [../../packages/evm-contracts/contracts/GoldClob.sol](../../packages/evm-contracts/contracts/GoldClob.sol) -- [x] Deploy receipts/manifest writers linked: - [../../packages/evm-contracts/scripts/deploy.ts](../../packages/evm-contracts/scripts/deploy.ts), - [../../packages/evm-contracts/scripts/deploy-duel-oracle.ts](../../packages/evm-contracts/scripts/deploy-duel-oracle.ts) +- [x] Soak workflow and runbook linked: + [../../.github/workflows/pm-soak.yml](../../.github/workflows/pm-soak.yml), + [../runbooks/pm-confidence-soak.md](../runbooks/pm-confidence-soak.md) +- [ ] Local Stage-A deploy and verify artifacts attached for Solana devnet and + BSC testnet +- [ ] Preserved AVAX Fuji artifacts attached only if the AVAX lane is being + reactivated or explicitly reviewed +- [ ] Read-only staged proof artifact bundle attached +- [ ] Canary-write staged proof artifact bundle attached with `pm`, `perps`, + and `amm` sub-results per chain +- [ ] Full app-shell acceptance evidence attached for wallet/account, + claims/positions, and points/referral surfaces +- [ ] Staged soak artifact bundle attached +- [ ] `verify-chains.json` attached and green + +## Launch-Chain Canonical Truth + +- [x] Shared registry and gate linked: + [../../packages/hyperbet-chain-registry/src/index.ts](../../packages/hyperbet-chain-registry/src/index.ts), + [../../scripts/ci-gate-registry.ts](../../scripts/ci-gate-registry.ts) +- [ ] Solana canonical `goldAmmMarketProgramId` committed from mainnet + deployment evidence +- [ ] BSC canonical PM, AMM, perps, and governance fields committed from + mainnet deployment evidence +- [ ] AVAX canonical PM, AMM, perps, and governance fields committed from + mainnet deployment evidence only if the AVAX lane is explicitly reactivated + +## Governance And Emergency Controls + - [x] Governance and signer runbooks linked: [../runbooks/prediction-market-governance-and-emergency-controls.md](../runbooks/prediction-market-governance-and-emergency-controls.md), [../runbooks/signer-policy-and-key-rotation.md](../runbooks/signer-policy-and-key-rotation.md) -- [ ] Production timelock, multisig, emergency, reporter, finalizer, and - challenger assignment tx hashes attached +- [ ] Ownership-transfer, signer, and freeze tx hashes attached for all + launch-critical surfaces +- [ ] Key-rotation completion recorded for any historically exposed deploy keys -## Gate 23: Launch Evidence Package +## Staging Provisioning + +- [ ] GitHub `staging` environment created +- [ ] Required `HYPERBET_*_STAGING_*` vars and secrets loaded +- [ ] Shared BSC testnet token addresses recorded in + [testnet-operations-ledger.md](testnet-operations-ledger.md) +- [ ] Shared AVAX token addresses recorded only if the AVAX lane is explicitly + reactivated +- [ ] Canary, admin, operator, and reporter wallets funded for staged proof and + staged soak + +## Audit Handoff Package - [x] Reviewer-facing release prep linked: [../prediction-market-release-prep.md](../prediction-market-release-prep.md) @@ -61,14 +87,10 @@ Use this checklist with the candidate memo in [../hyperbet-production-deploy.md](../hyperbet-production-deploy.md) - [x] Runbook index linked: [../runbooks/README.md](../runbooks/README.md) -- [x] Memo and checklist converted from blank templates into candidate docs - -## Gate 24: Audit Handoff Package - - [ ] ABI freeze files refreshed and attached: [abi/gold_clob.abi.json](abi/gold_clob.abi.json), [abi/duel_outcome_oracle.abi.json](abi/duel_outcome_oracle.abi.json) -- [x] Existing exploit/test evidence index linked: - [exploit-test-evidence-index.md](exploit-test-evidence-index.md) -- [ ] Engineer `1`, `3`, and `4` artifacts attached -- [ ] Final findings ledger and accepted residual risks attached +- [ ] Residual-risk register attached: + [residual-risk-register.md](residual-risk-register.md) +- [ ] Final findings ledger and accepted risks attached +- [ ] Product-claim statement attached for wallet/account and rewards durability diff --git a/docs/release/github-project-production-backlog.md b/docs/release/github-project-production-backlog.md new file mode 100644 index 00000000..75a428f5 --- /dev/null +++ b/docs/release/github-project-production-backlog.md @@ -0,0 +1,1650 @@ +# GitHub Project Production Backlog + +> **TL;DR:** This is the canonical issue-seed backlog for Hyperbet's full launch path. It covers the full product destination of `PM + perps + internal AMM`, the shared application shell and account surfaces around those products, uses `BSC + Solana` as the active production-readiness gate, and keeps `AVAX` visible as a parked follow-on epic rather than an active blocker. The implementation target is runtime-family based: shared `EVM` plus `SVM`, with `BSC` acting as the current active EVM wrapper. + +Detailed document ownership is defined in +[tracking-document-map.md](tracking-document-map.md). + +The canonical end-to-end runtime status view lives in +[runtime-integration-readiness-matrix.md](runtime-integration-readiness-matrix.md). + +## Scope Contract + +- full product destination: `PM + perps + internal AMM` +- active production-readiness gate: `BSC + Solana` +- parked follow-on epic: `AVAX` +- `AMM` remains internal infrastructure, not a required retail browser surface +- implementation target: shared `EVM` runtime plus `SVM` + +## Chain Generality Contract + +Interpret every ticket in this document with the following default: + +- `BSC` is the current active EVM proving wrapper, not the exclusive EVM + implementation target +- unless a ticket explicitly says `wrapper-specific`, `deployment-specific`, or + `governance/evidence-only`, EVM work should land in shared EVM packages, + registry-driven config, and reusable runtime/app surfaces +- `Solana` owns the active `SVM` lane +- future `Base` onboarding and any later `AVAX` reactivation should be able to + consume the same shared EVM path rather than require BSC-only rewrites +- if an issue is created from this backlog, it should be labeled or fielded as + one of: `evm-shared`, `svm`, `cross-runtime`, or `wrapper-specific` + +## How To Use This Backlog + +- each `##` section is an epic candidate +- each `###` section is an issue candidate +- copy the title directly into GitHub +- keep the acceptance criteria unless scope changes materially +- if work is off-repo or operational, keep it on the same board with an owner + +Suggested label families: + +- `priority:P0`, `priority:P1`, `priority:P2` +- `scope:active`, `scope:parked`, `scope:off-repo` +- `blocker:launch`, `blocker:quality`, `blocker:parked` +- `type:bug`, `type:feature`, `type:improvement`, `type:ci`, + `type:security`, `type:ops`, `type:docs` +- `runtime:evm-shared`, `runtime:svm`, `runtime:cross-runtime`, + `runtime:wrapper-specific` + +Suggested project fields: + +- `Status`: `Inbox`, `Needs Decision`, `Ready`, `In Progress`, `In Review`, + `In QA`, `Blocked`, `Done` +- `Workflow`: mirrored compatibility field with the same queue values until it + is retired deliberately +- `Priority`: `P0`, `P1`, `P2` +- `Scope`: `active`, `parked`, `off-repo` +- `Runtime Applicability`: `evm-shared`, `svm`, `cross-runtime`, + `wrapper-specific` +- `Work Type`: `bug`, `feature`, `improvement`, `ci`, `security`, `ops`, + `docs` +- `Parent issue` +- `Sub-issues progress` +- `Epic` +- `Blocker Class`: `launch-blocking`, `quality-blocking`, `parked`, + `non-blocking` +- `Evidence Required`: `yes`, `no` +- `Owner` + +Suggested Kanban status options: + +- `Inbox` +- `Needs Decision` +- `Ready` +- `In Progress` +- `In Review` +- `In QA` +- `Blocked` +- `Done` + +Suggested project views: + +- `All Work` +- `Launch Blockers` +- `Current Queue` +- `Parked` +- `Off-Repo` +- `By Runtime` + +Queue model: + +- do not use dated sprints or time promises in the canonical backlog +- `Status` is the board-driving queue field used for Kanban grouping and + day-to-day execution +- `Workflow` mirrors `Status` for compatibility until the board model is + simplified deliberately +- sequence only by `Status`, `Priority`, `Dependencies`, `Scope`, + `Runtime Applicability`, and `Blocker Class` +- if a lightweight queue field is needed, use `Current`, `Next`, `Later`, and + `Parked` + +## GitHub Project Conversion Contract + +- create one GitHub Project v2 named `Hyperbet Sprint` +- keep active, parked, and off-repo work on the same board +- create one umbrella issue per epic and one execution issue per `PROD-*` + ticket +- every umbrella issue must use native GitHub sub-issues as the authoritative + child-membership graph +- every `PROD-*` issue must have the correct native GitHub parent issue +- umbrella issue bodies must not be used as pseudo-sub-issue lists +- `Parent issue` and `Sub-issues progress` are required parts of the project + structure, not optional add-ons +- split broad active `P0` tickets into child execution issues before import + when they are too large to act on as single issues +- do not create new execution issues during hierarchy-alignment passes unless a + later explicit planning pass decides to split oversized tickets +- every created issue must carry explicit `Runtime applicability`, even when + the markdown entry relied on inference from the `Chain Generality Contract` + +## Already Landed + +Do not reopen these unless a concrete regression is found: + +- Stage-A browser-to-chain acceptance +- real local Hyperscapes duel lane +- direct canary and verify rails +- current release, freeze, threat-model, and deploy documentation +- explicit distinction between browser surfaces and internal AMM operations +- repo artifact-policy CI enforcement and removal of the previously flagged + tracked Solana deploy artifacts from the tracked tree +- develop-era launch trackers now preserved as historical snapshots rather than + live blocker owners + +## Reconciliation Notes + +This backlog is the reconciled canonical set after comparing: + +- the current repo state +- the canonical release-tracking docs +- the corrected local issue pack and corrected probe audit +- package-level application and SDK truth across `hyperbet-ui`, + `hyperbet-sdk`, `hyperbet-bsc`, `hyperbet-solana`, `hyperbet-evm`, and + `market-maker-bot` + +Important reconciliation outcomes: + +- earlier repo-hygiene items around tracked Solana deploy artifacts and artifact + policy are already closed in the current repo state and therefore remain in + `Already Landed`, not as open tickets +- the corrected probe did surface real open gaps that were previously tracked + too generically; those are now represented by `PROD-001A`, `PROD-007A`, + `PROD-015B`, and `PROD-015C` +- integration and runtime tickets below were sharpened so the canonical backlog + now explicitly owns event-feed promotion, durable checkpoints, idempotent + replay, coordinated staged smoke, and AMM settlement-model closure +- the final review pass also surfaced missing full-app work around wallet and + account surfaces, points/referrals, app-shell acceptance, shared keeper + convergence, orchestration explicitness, and market-maker integration; those + are now owned by `PROD-047` through `PROD-054` + +## Ticket Schema + +Every issue in this document uses the same required fields: + +- `ID` +- `Title` +- `Type` +- `Priority` +- `Scope` +- `Area` +- `Description` +- `Acceptance criteria` +- `Dependencies` +- `Source docs` +- `Suggested owner` +- `Blocker class` +- `Runtime applicability` + +When issue text below predates an explicit runtime line, infer it from the +`Chain Generality Contract` during GitHub issue conversion and make it explicit +on the created issue and project item. + +## Epic 1: Chain Truth And Deployment Integrity + +### PROD-001 Populate canonical active launch-chain registry truth from final receipts + +- ID: `PROD-001` +- Title: `Populate canonical active launch-chain registry truth from final receipts` +- Type: `feature` +- Priority: `P0` +- Scope: `active` +- Area: `registry` +- Description: Commit truthful production registry values for the active launch + chains, `BSC` and `Solana`, using final deployment receipts only, while + preserving registry shape and config semantics that future EVM chains can + reuse without forking the runtime contract. +- Acceptance criteria: + - `packages/hyperbet-chain-registry/src/index.ts` contains complete launch + values for `BSC` and `Solana` + - no active-scope registry placeholders remain + - registry gate passes in strict active-launch mode +- Dependencies: final deployment receipts, explorer verification +- Source docs: `launch-ops-evidence-index.md`, + `prediction-market-launch-freeze-tracker.md`, + `prediction-market-release-prep.md` +- Suggested owner: `protocol` +- Blocker class: `launch-blocking` + +### PROD-001A Align active launch constants and feature-flag truth with the current scope contract + +- ID: `PROD-001A` +- Title: `Align active launch constants and feature-flag truth with the current scope contract` +- Type: `bug` +- Priority: `P0` +- Scope: `active` +- Area: `registry` +- Description: Eliminate scope drift between the canonical registry, launch + ordering constants, and runtime feature truth so the repo stops implying an + active launch posture that no longer matches `BSC + Solana`. +- Acceptance criteria: + - `BETTING_LAUNCH_EVM_CHAIN_ORDER` reflects the active launch gate and no + longer implies parked `AVAX` + - PM, perps, and internal AMM enablement has one documented canonical source + of truth for active environments + - registry tests, runtime env resolution, and active release docs agree on + the same feature and chain-scope contract +- Dependencies: `PROD-001` +- Source docs: `prediction-market-release-prep.md`, + `production-readiness-audit-2026-03-29.md` +- Suggested owner: `protocol` +- Blocker class: `launch-blocking` + +### PROD-002 Freeze and publish a launch manifest from final receipts + +- ID: `PROD-002` +- Title: `Freeze and publish a launch manifest from final receipts` +- Type: `docs` +- Priority: `P0` +- Scope: `active` +- Area: `release` +- Description: Materialize a frozen launch manifest with addresses, + authorities, deployment versions, explorer links, and receipt hashes. +- Acceptance criteria: + - frozen manifest exists under `docs/release/manifests/` + - every launch-critical address maps back to a receipt + - manifest matches chain registry and verification outputs +- Dependencies: `PROD-001` +- Source docs: `pm-launch-execution-plan.md`, + `external-audit-package-checklist.md` +- Suggested owner: `release` +- Blocker class: `launch-blocking` + +### PROD-003 Close non-mainnet receipt packaging and evidence indexing + +- ID: `PROD-003` +- Title: `Close non-mainnet receipt packaging and evidence indexing` +- Type: `docs` +- Priority: `P1` +- Scope: `active` +- Area: `evidence` +- Description: Convert the current Stage-A evidence into a stable attachment set + that reviewers and operators can consume without hunting through artifact + folders. +- Acceptance criteria: + - deploy, init, freeze, and verify outputs are grouped by chain + - receipt index links work from repo docs + - browser/on-chain evidence can be traced to receipts +- Dependencies: none +- Source docs: `stage-a-browser-acceptance-matrix.md`, + `stage-a-promotion-execution-ledger.md`, + `launch-ops-evidence-index.md` +- Suggested owner: `release` +- Blocker class: `quality-blocking` + +### PROD-004 Replace SDK and app placeholder deployment constants with registry-driven config + +- ID: `PROD-004` +- Title: `Replace SDK and app placeholder deployment constants with registry-driven config` +- Type: `improvement` +- Priority: `P1` +- Scope: `active` +- Runtime applicability: `cross-runtime` +- Area: `sdk` +- Description: Remove placeholder addresses and hardcoded defaults from public + SDK and app entrypoints so runtime config resolves from the canonical + registry or frozen manifest instead of drifting into chain-specific + placeholders such as the current SDK default addresses. +- Acceptance criteria: + - no placeholder deployment constants remain in active SDK/app entrypoints, + including `packages/hyperbet-sdk/src/index.ts` + - BSC and Solana config resolution is tested and EVM config semantics remain + reusable for future wrappers + - docs point consumers to the registry or launch manifest +- Dependencies: `PROD-001` +- Source docs: `launch-ops-evidence-index.md`, + `system-design-alignment.md`, + `production-readiness-audit-2026-03-29.md` +- Suggested owner: `sdk` +- Blocker class: `quality-blocking` + +## Epic 2: Staged Environment, CI, Proof, And Soak + +### PROD-005 Provision the GitHub staging environment and staged secrets contract + +- ID: `PROD-005` +- Title: `Provision the GitHub staging environment and staged secrets contract` +- Type: `ci` +- Priority: `P0` +- Scope: `active` +- Area: `infra` +- Description: Create the real GitHub `staging` environment and provision the + staged Pages, Railway, RPC, signer, and publish secrets required by staged + proof and staged soak. +- Acceptance criteria: + - GitHub `staging` environment exists + - required `HYPERBET_*_STAGING_*` vars and secrets are loaded + - env audit passes without placeholder or shadow-truth failures +- Dependencies: infrastructure ownership, secret custody +- Source docs: `hyperbet-production-deploy.md`, + `launch-ops-evidence-index.md`, + `external-audit-package-checklist.md` +- Suggested owner: `infra` +- Blocker class: `launch-blocking` + +### PROD-006 Capture staged proof artifacts for PM, perps/models, and internal AMM + +- ID: `PROD-006` +- Title: `Capture staged proof artifacts for PM, perps/models, and internal AMM` +- Type: `ci` +- Priority: `P0` +- Scope: `active` +- Area: `validation` +- Description: Execute staged proof in read-only and canary-write modes and + archive the full artifact bundle. +- Acceptance criteria: + - staged proof produces chain-by-chain `pm`, `perps`, and `amm` results + - `verify-chains.json` is attached and green + - artifact bundle is linked from evidence and audit docs +- Dependencies: `PROD-005` +- Source docs: `launch-ops-evidence-index.md`, + `external-audit-package-checklist.md` +- Suggested owner: `qa` +- Blocker class: `launch-blocking` + +### PROD-007 Capture authoritative staged soak evidence + +- ID: `PROD-007` +- Title: `Capture authoritative staged soak evidence` +- Type: `ci` +- Priority: `P0` +- Scope: `active` +- Area: `validation` +- Description: Run the staged soak rail against the provisioned environment and + archive the output with a reviewer-facing summary. +- Acceptance criteria: + - real staged soak artifacts exist under `.ci-artifacts/pm-soak` + - summary and screenshots are linked from release docs + - incidents are resolved or explicitly accepted +- Dependencies: `PROD-005` +- Source docs: `launch-ops-evidence-index.md`, + `external-audit-package-checklist.md` +- Suggested owner: `qa` +- Blocker class: `launch-blocking` + +### PROD-007A Run coordinated full-product staged smoke and publish one immutable evidence bundle + +- ID: `PROD-007A` +- Title: `Run coordinated full-product staged smoke and publish one immutable evidence bundle` +- Type: `ci` +- Priority: `P0` +- Scope: `active` +- Area: `validation` +- Description: Prove the coordinated loop of game feed, keeper ingestion, app + interaction, on-chain posting, result pickup, and recovery in one staged + environment for every enabled product surface, then publish one candidate + evidence bundle that reviewers can download directly. +- Acceptance criteria: + - one staged run proves the coordinated PM loop across the active launch + chains + - perps staged smoke is captured on the active EVM lane without baking in + BSC-only assumptions that would block later EVM onboardings + - internal AMM smoke is captured according to the final AMM scope decision + - one immutable evidence bundle contains browser artifacts, tx hashes, + receipts, checkpoints, and run summaries for the candidate +- Dependencies: `PROD-006`, `PROD-007`, `PROD-014A`, `PROD-015`, + `PROD-015A`, `PROD-015B` +- Source docs: `runtime-integration-readiness-matrix.md`, + `launch-ops-evidence-index.md`, + `prediction-market-release-prep.md` +- Suggested owner: `qa` +- Blocker class: `launch-blocking` + +### PROD-008 Rehearse production deploy and rollback as a controlled ceremony + +- ID: `PROD-008` +- Title: `Rehearse production deploy and rollback as a controlled ceremony` +- Type: `ops` +- Priority: `P1` +- Scope: `active` +- Area: `deploy` +- Description: Turn the production deploy guide into an executed ceremony + rehearsal that covers deploy, health verification, rollback trigger, and + rollback validation. +- Acceptance criteria: + - deployment checklist is executed against a non-mainnet environment + - rollback path is written and tested + - operator ownership is documented +- Dependencies: `PROD-006`, `PROD-007` +- Source docs: `hyperbet-production-deploy.md` +- Suggested owner: `infra` +- Blocker class: `quality-blocking` + +## Epic 3: Governance, Key Management, And Custody + +### PROD-009 Finish multisig, timelock, and upgrade-authority custody for the active launch chains + +- ID: `PROD-009` +- Title: `Finish multisig, timelock, and upgrade-authority custody for the active launch chains` +- Type: `ops` +- Priority: `P0` +- Scope: `active` +- Area: `governance` +- Description: Complete the real governance custody model assumed by the threat + model for `BSC` and `Solana`. +- Acceptance criteria: + - BSC timelock and multisig custody is live and recorded + - Solana upgrade authority is transferred from the deployer + - chain registry and governance docs reference the same owners +- Dependencies: final deployment addresses +- Source docs: `threat-model.md`, + `contract-privileged-surface-inventory.md`, + `pm-launch-execution-plan.md` +- Suggested owner: `protocol-ops` +- Blocker class: `launch-blocking` + +### PROD-010 Execute and record final freeze transactions for every privileged surface + +- ID: `PROD-010` +- Title: `Execute and record final freeze transactions for every privileged surface` +- Type: `ops` +- Priority: `P0` +- Scope: `active` +- Area: `governance` +- Description: Run the final freeze steps for oracle, market, perps, and AMM + surfaces and record the tx hashes in the evidence bundle. +- Acceptance criteria: + - all freeze tx hashes are captured and linked + - release docs no longer refer to freeze as pending + - privileged-surface inventory is updated with final evidence +- Dependencies: `PROD-009` +- Source docs: `prediction-market-launch-freeze-tracker.md`, + `contract-privileged-surface-inventory.md` +- Suggested owner: `protocol-ops` +- Blocker class: `launch-blocking` + +### PROD-011 Rotate and retire historical deploy and test keys + +- ID: `PROD-011` +- Title: `Rotate and retire historical deploy and test keys` +- Type: `security` +- Priority: `P0` +- Scope: `active` +- Area: `key-management` +- Description: Treat any keys used during prior testnet, branch, or local + proving phases as exposed and replace them with final production custody. +- Acceptance criteria: + - historical keys are marked retired + - active signer inventory is published + - key-rotation completion is attached to the audit packet +- Dependencies: `PROD-009` +- Source docs: `external-audit-package-checklist.md`, + `signer-policy-and-key-rotation.md` +- Suggested owner: `security` +- Blocker class: `launch-blocking` + +### PROD-012 Rehearse emergency governance actions with named owners + +- ID: `PROD-012` +- Title: `Rehearse emergency governance actions with named owners` +- Type: `ops` +- Priority: `P1` +- Scope: `active` +- Area: `governance` +- Description: Run pause, cancel, and role-recovery drills against the final + custody model so governance is operationally usable. +- Acceptance criteria: + - emergency drill report exists + - owner list and escalation chain are documented + - runbooks reflect final custody addresses and steps +- Dependencies: `PROD-009`, `PROD-010` +- Source docs: `prediction-market-governance-and-emergency-controls.md` +- Suggested owner: `protocol-ops` +- Blocker class: `quality-blocking` + +## Epic 4: Hyperscapes Integration And Runtime Contract Stability + +### PROD-013 Converge BSC onto the canonical EVM runtime and keeper core + +- ID: `PROD-013` +- Title: `Converge BSC onto the canonical EVM runtime and keeper core` +- Type: `improvement` +- Priority: `P1` +- Scope: `active` +- Runtime applicability: `evm-shared` +- Area: `runtime` +- Description: Reduce chain-wrapper drift so BSC remains only the active EVM + proving shell over one canonical EVM runtime and shared keeper core, rather + than becoming the place where EVM-specific business logic accumulates. +- Acceptance criteria: + - chain-specific business logic is minimized in the BSC wrapper + - shared runtime modules own common behavior + - future EVM chains can onboard against the same shared runtime with only + documented wrapper/config differences + - convergence docs match reality +- Dependencies: none +- Source docs: `system-design-alignment.md` +- Suggested owner: `protocol` +- Blocker class: `quality-blocking` + +### PROD-014 Version and contract-test the Hyperscapes to Hyperbet integration boundary + +- ID: `PROD-014` +- Title: `Version and contract-test the Hyperscapes to Hyperbet integration boundary` +- Type: `feature` +- Priority: `P0` +- Scope: `active` +- Area: `integration` +- Description: Formalize the upstream duel lifecycle contract so releases can + pin to a schema and catch breaking changes before runtime. +- Acceptance criteria: + - one canonical upstream contract is identified explicitly + - the contract covers `schemaVersion`, `sourceEpoch`, `seq`, timing fields, + winner mapping, and reset/replay semantics + - contract tests cover duel lifecycle, timing, result mapping, and resets +- Dependencies: active Hyperscapes contract access +- Source docs: `production-readiness-audit-2026-03-29.md`, + `prediction-market-test-flow.md`, + `hyperscapes-local-pm-integration.md` +- Suggested owner: `integration` +- Blocker class: `launch-blocking` + +### PROD-014A Canonicalize keeper ingestion onto the versioned Hyperscapes betting feed + +- ID: `PROD-014A` +- Title: `Canonicalize keeper ingestion onto the versioned Hyperscapes betting feed` +- Type: `bug` +- Priority: `P0` +- Scope: `active` +- Runtime applicability: `cross-runtime` +- Area: `keeper` +- Description: Stop treating the loose raw `/api/streaming/state` payload as a + production contract where a richer betting-feed contract already exists. +- Acceptance criteria: + - BSC and Solana consume the canonical `/api/internal/bet-sync/state` and + `/api/internal/bet-sync/events` contract, or a documented compatibility + adapter with equivalent semantics + - env examples and deploy docs prefer the canonical feed contract + - local and staged runners no longer depend on implicit feed-shape knowledge + and expose the consumer checkpoint boundary explicitly + - the canonical feed path is described as shared runtime contract rather than + a single-wrapper behavior +- Dependencies: `PROD-014` +- Source docs: `production-readiness-audit-2026-03-29.md`, + `hyperscapes-local-pm-integration.md` +- Suggested owner: `integration` +- Blocker class: `launch-blocking` + +### PROD-014B Make feed parsing fail closed on schema drift, replay mismatch, and source resets + +- ID: `PROD-014B` +- Title: `Make feed parsing fail closed on schema drift, replay mismatch, and source resets` +- Type: `security` +- Priority: `P0` +- Scope: `active` +- Runtime applicability: `cross-runtime` +- Area: `keeper` +- Description: Harden keeper feed parsing so contract drift or replay + corruption surfaces as an explicit integration error instead of silently + becoming synthesized state. +- Acceptance criteria: + - production-mode parsers reject missing required sequencing or version fields + - source-epoch changes, replay windows, and reset events are tracked and + surfaced as integration errors when contracts diverge + - missing `seq`, `emittedAt`, or equivalent fields are not silently fabricated + - the failure model is consistent across the active EVM and SVM keepers +- Dependencies: `PROD-014` +- Source docs: `production-readiness-audit-2026-03-29.md`, + `hyperscapes-local-pm-integration.md` +- Suggested owner: `integration` +- Blocker class: `launch-blocking` + +### PROD-015 Close remaining EVM models/perps browser deltas + +- ID: `PROD-015` +- Title: `Close remaining EVM models/perps browser deltas` +- Type: `feature` +- Priority: `P0` +- Scope: `active` +- Runtime applicability: `evm-shared` +- Area: `ui` +- Description: Productionize the user-facing EVM `models/agents` surface so the + product makes a precise claim about what users can do on the active EVM lane, + with BSC as the current proving wrapper rather than a hard-coded product + boundary. +- Acceptance criteria: + - intended active-EVM user actions are documented and tested + - browser-to-chain evidence exists for the supported models/perps actions, + including receipts or tx hashes + - stale-oracle, paused-market, and insufficient-margin UI handling is + exercised and evidenced for the supported flows + - the supported user actions are described as shared EVM product behavior, + with wrapper-specific differences called out separately + - unsupported behaviors are explicit in product copy and docs + - the resulting implementation does not require BSC-only logic where a shared + EVM path is intended + - browser acceptance evidence reflects the final active product claim +- Dependencies: product decision on EVM models/perps scope +- Source docs: `stage-a-browser-acceptance-matrix.md`, + `prediction-market-release-prep.md` +- Suggested owner: `app` +- Blocker class: `launch-blocking` + +### PROD-015A Close active-scope internal AMM and liquidity runtime dependencies + +- ID: `PROD-015A` +- Title: `Close active-scope internal AMM and liquidity runtime dependencies` +- Type: `feature` +- Priority: `P0` +- Scope: `active` +- Runtime applicability: `evm-shared` +- Area: `market-making` +- Description: Close the remaining launch-path gaps for the internal AMM and + active EVM liquidity dependencies, using BSC as the current proving wrapper + without turning AMM into a retail UI scope item or baking BSC-only logic into + the shared EVM path. +- Acceptance criteria: + - required active-EVM token and router inputs are finalized, with current BSC + proving inputs documented explicitly + - internal AMM runtime dependencies are documented and testable + - internal-only AMM operator expectations are explicit in docs, staged smoke, + and release evidence + - staged proof and launch docs treat AMM as internal but production-critical + - the dependency contract is reusable for future EVM wrappers without + reopening BSC-only assumptions +- Dependencies: `PROD-001`, `PROD-005`, `PROD-006`, `PROD-015B` +- Source docs: `prediction-market-release-prep.md`, + `launch-ops-evidence-index.md`, + `runtime-integration-readiness-matrix.md` +- Suggested owner: `protocol` +- Blocker class: `launch-blocking` + +### PROD-015B Decide and freeze the production AMM settlement model + +- ID: `PROD-015B` +- Title: `Decide and freeze the production AMM settlement model` +- Type: `feature` +- Priority: `P0` +- Scope: `active` +- Area: `market-making` +- Description: The EVM AMM still exposes both challenge-window settlement and + oracle-driven settlement. Freeze the production truth so auditors and + operators do not have to infer whether AMM is oracle-only or dual-mode. +- Acceptance criteria: + - the production AMM settlement truth is explicit and documented + - if oracle-only, proposer/challenge settlement is disabled or tightly + guarded for production markets + - if dual-mode, the governing rules, tests, and runbooks are exhaustive and + audit-grade +- Dependencies: product and protocol signoff +- Source docs: `prediction-market-release-prep.md`, + `production-readiness-audit-2026-03-29.md`, + `pm-launch-execution-plan.md` +- Suggested owner: `protocol` +- Blocker class: `launch-blocking` + +### PROD-015C Harden Solana AMM settlement account typing and auditability + +- ID: `PROD-015C` +- Title: `Harden Solana AMM settlement account typing and auditability` +- Type: `security` +- Priority: `P1` +- Scope: `active` +- Area: `market-making` +- Description: Make the Solana AMM settlement path defensible to auditors by + either tightening optional account typing or documenting and exhaustively + testing the compatibility posture. +- Acceptance criteria: + - settlement accounts are strongly constrained or the compatibility exception + is documented explicitly + - negative tests cover wrong owner, wrong PDA, and incompatible optional + account cases + - the audit packet explains the final Solana AMM settlement validation model +- Dependencies: `PROD-015B` +- Source docs: `prediction-market-release-prep.md`, + `production-readiness-audit-2026-03-29.md` +- Suggested owner: `solana-protocol` +- Blocker class: `quality-blocking` + +### PROD-016 Stabilize public API and SDK contracts against chain truth + +- ID: `PROD-016` +- Title: `Stabilize public API and SDK contracts against chain truth` +- Type: `improvement` +- Priority: `P1` +- Scope: `active` +- Runtime applicability: `cross-runtime` +- Area: `api` +- Description: Publish one stable contract for keeper APIs, stream APIs, chain + addressing, SDK entrypoints, and application-level config resolution so + consumers stop relying on incidental implementation details. +- Acceptance criteria: + - API contract doc exists + - SDKs consume canonical config paths + - app shells consume the same contract for addresses, feature flags, and + runtime capabilities + - compatibility policy is documented for breaking changes +- Dependencies: `PROD-001`, `PROD-014` +- Source docs: `system-design-alignment.md`, + `tracking-document-map.md` +- Suggested owner: `sdk` +- Blocker class: `quality-blocking` + +## Epic 5: Application Shell, Account Surfaces, And Runtime Convergence + +### PROD-047 Productionize shared wallet and account surfaces across the active runtimes + +- ID: `PROD-047` +- Title: `Productionize shared wallet and account surfaces across the active runtimes` +- Type: `feature` +- Priority: `P1` +- Scope: `active` +- Runtime applicability: `cross-runtime` +- Area: `app-shell` +- Description: Make wallet connect/select, account state, claimability, + positions, and account-facing copy behave as one deliberate Hyperbet product + across the active runtimes rather than a set of partially chain-specific + surfaces. +- Acceptance criteria: + - shared wallet and account surfaces are documented for the active `EVM` and + `SVM` lanes + - launch-critical account actions such as connect, view positions, and claim + state are covered by acceptance evidence + - chain-specific differences are explicit in copy and docs rather than + emerging from wrapper drift +- Dependencies: `PROD-016` +- Source docs: `system-design-alignment.md`, + `stage-a-browser-acceptance-matrix.md`, + `production-readiness-audit-2026-03-29.md` +- Suggested owner: `app` +- Blocker class: `quality-blocking` + +### PROD-048 Productionize points, referrals, and rewards surfaces as first-class product scope + +- ID: `PROD-048` +- Title: `Productionize points, referrals, and rewards surfaces as first-class product scope` +- Type: `feature` +- Priority: `P1` +- Scope: `active` +- Runtime applicability: `cross-runtime` +- Area: `rewards` +- Description: Treat points, referrals, leaderboard, invite redeem, and wallet + linking as explicit product surfaces with clear durability, auth, and support + expectations rather than incidental add-ons. +- Acceptance criteria: + - points, referrals, and rewards product claims are documented explicitly + - durable versus cached or reconstructable rewards state is stated in docs + - browser evidence exists for the active account and referral flows the + product claims to support + - unsupported or operator-mediated rewards behaviors are explicit in product + copy and support docs +- Dependencies: `PROD-021`, `PROD-047`, `PROD-050` +- Source docs: `production-readiness-audit-2026-03-29.md`, + `stage-a-browser-acceptance-matrix.md`, + `hyperbet-production-deploy.md` +- Suggested owner: `app` +- Blocker class: `quality-blocking` + +### PROD-049 Freeze the active app-shell acceptance contract for the full Hyperbet surface + +- ID: `PROD-049` +- Title: `Freeze the active app-shell acceptance contract for the full Hyperbet surface` +- Type: `feature` +- Priority: `P0` +- Scope: `active` +- Runtime applicability: `cross-runtime` +- Area: `acceptance` +- Description: Define and prove the user-facing Hyperbet shell beyond pure + market lifecycle flows so launch claims cover the full active application: + navigation, drawers, tabs, models/agents, claims, positions, account panels, + and explicit unsupported-state messaging. +- Acceptance criteria: + - the active application shell contract is documented in one canonical place + - every visible active-scope app shell surface maps to acceptance evidence or + an explicit unsupported-state statement + - app-shell acceptance is expressed as shared product truth, not as wrapper + folklore +- Dependencies: `PROD-015`, `PROD-047`, `PROD-048` +- Source docs: `stage-a-browser-acceptance-matrix.md`, + `runtime-integration-readiness-matrix.md`, + `prediction-market-release-prep.md` +- Suggested owner: `app` +- Blocker class: `launch-blocking` + +### PROD-050 Add durable user-account and rewards reconciliation tooling + +- ID: `PROD-050` +- Title: `Add durable user-account and rewards reconciliation tooling` +- Type: `feature` +- Priority: `P1` +- Scope: `active` +- Runtime applicability: `cross-runtime` +- Area: `ops-tooling` +- Description: Extend operator tooling beyond settlement only so support and + finance can reconstruct points, referrals, wallet-link state, claimability, + and account-facing reward questions deterministically. +- Acceptance criteria: + - operator reports can answer account, referral, and points disputes + - rewards and account state can be reconciled against keeper and chain + evidence where applicable + - support and finance runbooks describe the investigation path +- Dependencies: `PROD-019`, `PROD-048` +- Source docs: `production-readiness-audit-2026-03-29.md`, + `hyperbet-production-deploy.md` +- Suggested owner: `ops` +- Blocker class: `quality-blocking` + +### PROD-051 Extract shared keeper domain logic out of wrapper packages + +- ID: `PROD-051` +- Title: `Extract shared keeper domain logic out of wrapper packages` +- Type: `improvement` +- Priority: `P1` +- Scope: `active` +- Runtime applicability: `cross-runtime` +- Area: `runtime` +- Description: Move reusable keeper domain logic into shared modules so wrapper + packages stop accumulating chain-family business behavior and the shared + runtime contract becomes the default implementation path. +- Acceptance criteria: + - shared keeper modules own common domain behavior where runtime semantics + are actually shared + - wrapper packages are reduced toward execution adapters plus config + - convergence docs accurately describe the resulting ownership model +- Dependencies: `PROD-013` +- Source docs: `system-design-alignment.md`, + `production-readiness-audit-2026-03-29.md` +- Suggested owner: `protocol` +- Blocker class: `quality-blocking` + +### PROD-052 Make Hyperscapes orchestration runtime-explicit and wrapper-agnostic + +- ID: `PROD-052` +- Title: `Make Hyperscapes orchestration runtime-explicit and wrapper-agnostic` +- Type: `improvement` +- Priority: `P1` +- Scope: `active` +- Runtime applicability: `cross-runtime` +- Area: `integration` +- Description: Update local and staged orchestration so Hyperbet runtime + selection is explicit and no older Solana-first or wrapper-specific + assumptions survive in the duel-stack control plane. +- Acceptance criteria: + - orchestration scripts describe runtime selection explicitly + - no active runner depends on an implicit Solana-first sibling-app contract + - local and staged integration docs match the resulting orchestration model +- Dependencies: `PROD-014A`, `PROD-051` +- Source docs: `system-design-alignment.md`, + `hyperscapes-local-pm-integration.md` +- Suggested owner: `integration` +- Blocker class: `quality-blocking` + +### PROD-053 Canonicalize market-maker integration on shared runtime interfaces + +- ID: `PROD-053` +- Title: `Canonicalize market-maker integration on shared runtime interfaces` +- Type: `improvement` +- Priority: `P1` +- Scope: `active` +- Runtime applicability: `cross-runtime` +- Area: `market-making` +- Description: Align the market-maker integration with the same registry, + runtime, and interface contracts that the rest of Hyperbet uses so internal + AMM operations do not depend on ad hoc cross-chain behavior. +- Acceptance criteria: + - market-maker config resolves from canonical runtime and registry truth + - shared runtime interfaces are the documented integration contract + - release and ops docs describe the market-maker as part of the same canonical + launch system +- Dependencies: `PROD-015A`, `PROD-016`, `PROD-051` +- Source docs: `system-design-alignment.md`, + `prediction-market-release-prep.md` +- Suggested owner: `protocol` +- Blocker class: `quality-blocking` + +### PROD-054 Normalize active-scope package READMEs and launch docs to the chain-generality contract + +- ID: `PROD-054` +- Title: `Normalize active-scope package READMEs and launch docs to the chain-generality contract` +- Type: `docs` +- Priority: `P2` +- Scope: `active` +- Runtime applicability: `cross-runtime` +- Area: `docs` +- Description: Remove active-scope wording that can mislead contributors into + treating `BSC` as the permanent EVM boundary or PM as the only product lane, + while preserving historical evidence where appropriate. +- Acceptance criteria: + - active READMEs and guidance docs use the same shared `EVM` plus `SVM` + contract + - historical documents remain preserved without regaining open-work ownership + - contributors can tell which work is wrapper-specific versus shared-runtime + work +- Dependencies: `PROD-013`, `PROD-049` +- Source docs: `tracking-document-map.md`, + `prediction-market-release-prep.md`, + `production-readiness-audit-2026-03-29.md` +- Suggested owner: `release` +- Blocker class: `non-blocking` + +## Epic 6: Reliability, Durability, And Reconciliation + +### PROD-017 Replace ephemeral keeper storage with durable persistence + +- ID: `PROD-017` +- Title: `Replace ephemeral keeper storage with durable persistence` +- Type: `feature` +- Priority: `P0` +- Scope: `active` +- Area: `keeper` +- Description: Move keeper state off ephemeral SQLite semantics in hosted + environments and define backup, restore, and migration behavior. +- Acceptance criteria: + - durable storage backend is selected and wired + - backup and restore runbook exists + - hosted keeper deploy no longer depends on ephemeral local files +- Dependencies: production infra choice +- Source docs: `hyperbet-production-deploy.md` +- Suggested owner: `infra` +- Blocker class: `launch-blocking` + +### PROD-018 Add production observability, SLOs, and alerting + +- ID: `PROD-018` +- Title: `Add production observability, SLOs, and alerting` +- Type: `ops` +- Priority: `P0` +- Scope: `active` +- Area: `observability` +- Description: Add metrics, structured alerts, dashboards, and paging criteria + for keeper health, duel freshness, quote health, claim latency, and RPC + degradation. +- Acceptance criteria: + - SLOs are defined for critical surfaces + - alert thresholds and owners are documented + - dashboards exist for operators +- Dependencies: `PROD-017` +- Source docs: `hyperbet-production-deploy.md`, + `runtime-integration-readiness-matrix.md` +- Suggested owner: `ops` +- Blocker class: `launch-blocking` + +### PROD-018A Add stream freshness, restart recovery, replay, and backfill automation + +- ID: `PROD-018A` +- Title: `Add stream freshness, restart recovery, replay, and backfill automation` +- Type: `feature` +- Priority: `P0` +- Scope: `active` +- Area: `runtime` +- Description: Turn the currently manual recovery and replay assumptions into + explicit production automation for stale stream detection, resets, restart + recovery, and backfill. +- Acceptance criteria: + - stream freshness and reset conditions are measured and alertable + - consumer checkpoints survive restart and are queryable for operators + - keeper restart and Hyperscapes restart paths have deterministic backfill + behavior + - duplicate or replayed source events are applied idempotently + - the proof harness can show source events, consumer checkpoints, and + projected state together + - replay, reset, and backfill behavior is documented and tested +- Dependencies: `PROD-014A`, `PROD-014B`, `PROD-017` +- Source docs: `runtime-integration-readiness-matrix.md`, + `stage-a-browser-acceptance-matrix.md` +- Suggested owner: `integration` +- Blocker class: `launch-blocking` + +### PROD-019 Build result discovery, settlement reconciliation, and payout investigation tooling + +- ID: `PROD-019` +- Title: `Build result discovery, settlement reconciliation, and payout investigation tooling` +- Type: `feature` +- Priority: `P0` +- Scope: `active` +- Runtime applicability: `cross-runtime` +- Area: `ops-tooling` +- Description: Create operator tooling for result correlation, payout disputes, + claim backlog review, market-state reconstruction, user-facing accounting + questions, and the investigation primitives that higher-level account and + rewards tooling will build on. +- Acceptance criteria: + - chain and keeper events can be reconciled into one operator report + - result discovery and settlement correlation are deterministic + - payout investigation flow is documented + - the tool contract is reusable by account, rewards, and support workflows +- Dependencies: `PROD-017`, `PROD-018A` +- Source docs: `production-readiness-audit-2026-03-29.md`, + `runtime-integration-readiness-matrix.md` +- Suggested owner: `ops` +- Blocker class: `launch-blocking` + +### PROD-020 Harden production incident handling and support operations + +- ID: `PROD-020` +- Title: `Harden production incident handling and support operations` +- Type: `ops` +- Priority: `P1` +- Scope: `active` +- Area: `support` +- Description: Complete the operator side of a real betting service: support + queue routing, incident communication templates, and recovery ownership. +- Acceptance criteria: + - support and incident severity matrix exists + - operator handoff process is documented + - customer-facing incident communication templates are ready +- Dependencies: `PROD-018`, `PROD-019` +- Source docs: `production-readiness-audit-2026-03-29.md`, + `docs/runbooks/README.md` +- Suggested owner: `ops` +- Blocker class: `quality-blocking` + +## Epic 7: Security Hardening And External Audit + +### PROD-021 Make keeper write auth fail closed across the active keeper surfaces + +- ID: `PROD-021` +- Title: `Make keeper write auth fail closed across the active keeper surfaces` +- Type: `security` +- Priority: `P0` +- Scope: `active` +- Area: `keeper` +- Description: Align the BSC and Solana keepers with the canonical EVM keeper + so missing write keys reject writes instead of granting access. +- Acceptance criteria: + - `requireWriteAuth()` fails closed when the configured key is unset + - invite redeem, wallet link, external bet recording, and stream publish + reject missing or incorrect auth + - automated tests cover configured, missing-key, and wrong-key cases +- Dependencies: none +- Source docs: `production-readiness-audit-2026-03-29.md`, + `threat-model.md` +- Suggested owner: `security` +- Blocker class: `launch-blocking` + +### PROD-022 Remove origin-based trust from the Solana sender proxy + +- ID: `PROD-022` +- Title: `Remove origin-based trust from the Solana sender proxy` +- Type: `security` +- Priority: `P0` +- Scope: `active` +- Area: `keeper` +- Description: Replace the Solana sender proxy's current allowed-origin + authorization path with an explicit trusted service contract. +- Acceptance criteria: + - no auth decision depends on `Origin` alone + - the proxy requires a privileged key or signed service identity + - spoofed-origin tests are rejected +- Dependencies: `PROD-021` +- Source docs: `production-readiness-audit-2026-03-29.md`, + `stage-a-browser-acceptance-matrix.md` +- Suggested owner: `security` +- Blocker class: `launch-blocking` + +### PROD-023 Enforce read-only allowlists and quotas on public RPC proxies + +- ID: `PROD-023` +- Title: `Enforce read-only allowlists and quotas on public RPC proxies` +- Type: `security` +- Priority: `P0` +- Scope: `active` +- Area: `proxy` +- Description: Bring the keeper RPC proxies in line with their documented + contract as public keyed read-only endpoints. +- Acceptance criteria: + - Solana and EVM proxy handlers enforce approved read-only method sets + - state-changing or unknown methods are rejected before upstream forwarding + - usage is rate-limited and observable per method family +- Dependencies: none +- Source docs: `production-readiness-audit-2026-03-29.md`, + `hyperbet-production-deploy.md` +- Suggested owner: `security` +- Blocker class: `launch-blocking` + +### PROD-024 Close the remaining explicit security test gaps + +- ID: `PROD-024` +- Title: `Close the remaining explicit security test gaps` +- Type: `security` +- Priority: `P0` +- Scope: `active` +- Area: `tests` +- Description: Finish the concrete adversarial tests still called out by the + risk and historical audit docs. +- Acceptance criteria: + - malicious-contract reentrancy test exists and passes + - flash-loan or single-tx abuse scenarios are covered where applicable + - residual-risk register is updated to reflect the new status +- Dependencies: `PROD-021`, `PROD-022`, `PROD-023` +- Source docs: `residual-risk-register.md`, + `convergence-audit-report.md` +- Suggested owner: `security` +- Blocker class: `launch-blocking` + +### PROD-025 Assemble the frozen external audit packet + +- ID: `PROD-025` +- Title: `Assemble the frozen external audit packet` +- Type: `docs` +- Priority: `P0` +- Scope: `active` +- Area: `audit` +- Description: Convert the current audit-prep state into a frozen packet that + an external reviewer can consume without chasing live branch drift. +- Acceptance criteria: + - RC commit and freeze manifest are attached + - staged proof and staged soak artifacts are attached + - coordinated staged smoke and candidate evidence bundle are attached + - governance receipts and final findings ledger are attached +- Dependencies: `PROD-006`, `PROD-007`, `PROD-007A`, `PROD-010`, `PROD-011`, + `PROD-015B`, `PROD-015C`, `PROD-024` +- Source docs: `external-audit-package-checklist.md` +- Suggested owner: `release` +- Blocker class: `launch-blocking` + +### PROD-026 Run the external audit and close the remediation loop + +- ID: `PROD-026` +- Title: `Run the external audit and close the remediation loop` +- Type: `security` +- Priority: `P0` +- Scope: `active` +- Area: `audit` +- Description: Complete the external audit engagement, land required fixes, and + record the final accepted risks and remediated findings. +- Acceptance criteria: + - external findings are logged in one ledger + - fixes are linked to evidence and code changes + - remaining accepted risks have explicit owner signoff +- Dependencies: `PROD-025` +- Source docs: `external-audit-package-checklist.md` +- Suggested owner: `security` +- Blocker class: `launch-blocking` + +### PROD-027 Add coverage reporting and invariant verification to the release packet + +- ID: `PROD-027` +- Title: `Add coverage reporting and invariant verification to the release packet` +- Type: `improvement` +- Priority: `P1` +- Scope: `active` +- Area: `tests` +- Description: Make test quality visible by attaching coverage, invariant, and + adversarial verification summaries to the release and audit packet. +- Acceptance criteria: + - quantitative coverage reports exist for critical packages + - invariant and fuzz suites are linked from the packet + - release docs reference the reports as evidence +- Dependencies: `PROD-024` +- Source docs: `convergence-audit-report.md` +- Suggested owner: `qa` +- Blocker class: `quality-blocking` + +## Epic 8: Gold Asset Architecture + +### PROD-028 Approve the final Gold architecture spec + +- ID: `PROD-028` +- Title: `Approve the final Gold architecture spec` +- Type: `docs` +- Priority: `P1` +- Scope: `active` +- Area: `gold` +- Description: Resolve the canonical Gold model so the product stops making + stronger claims than the implemented asset semantics can support. +- Acceptance criteria: + - canonical supply source is chosen + - issuance, burn, redemption, and chain-support rules are explicit + - supported vs unsupported behavior is stated plainly +- Dependencies: cross-team product decision +- Source docs: `gold-architecture-spec-plan.md`, + `gold-current-state.md` +- Suggested owner: `product` +- Blocker class: `quality-blocking` + +### PROD-029 Align registry, SDK, and product copy with the approved Gold model + +- ID: `PROD-029` +- Title: `Align registry, SDK, and product copy with the approved Gold model` +- Type: `improvement` +- Priority: `P1` +- Scope: `active` +- Area: `gold` +- Description: Once the Gold model is approved, remove misleading aliases and + update runtime semantics, copy, and verification to match it. +- Acceptance criteria: + - field naming matches the approved asset model + - unsupported Gold behaviors are removed or labeled + - verification logic no longer implies a broader rollout than exists +- Dependencies: `PROD-028` +- Source docs: `gold-architecture-spec-plan.md` +- Suggested owner: `sdk` +- Blocker class: `quality-blocking` + +### PROD-030 Implement Gold issuance, redemption, and reconciliation after the spec is approved + +- ID: `PROD-030` +- Title: `Implement Gold issuance, redemption, and reconciliation after the spec is approved` +- Type: `feature` +- Priority: `P2` +- Scope: `active` +- Area: `gold` +- Description: Treat Gold portability and backing as a separate build track + after the architecture is fixed, not as an incidental side effect of launch. +- Acceptance criteria: + - issuance and redemption flows are implemented against the approved model + - reserve and reconciliation invariants are enforced + - Gold-specific runbooks and audit scope exist +- Dependencies: `PROD-028`, `PROD-029` +- Source docs: `gold-architecture-spec-plan.md` +- Suggested owner: `protocol` +- Blocker class: `non-blocking` + +## Epic 9: Compliance, Treasury, And Customer Operations + +### PROD-031 Define jurisdiction, licensing, KYC/AML, and responsible-gaming posture + +- ID: `PROD-031` +- Title: `Define jurisdiction, licensing, KYC/AML, and responsible-gaming posture` +- Type: `ops` +- Priority: `P0` +- Scope: `off-repo` +- Area: `compliance` +- Description: A production betting product needs an explicit legal and + compliance operating model. +- Acceptance criteria: + - supported jurisdictions are defined + - KYC/AML policy is approved + - age gating and responsible-gaming requirements are specified +- Dependencies: legal and compliance ownership +- Source docs: `production-readiness-audit-2026-03-29.md` +- Suggested owner: `legal` +- Blocker class: `launch-blocking` + +### PROD-032 Ship legal disclosures and account-policy UX + +- ID: `PROD-032` +- Title: `Ship legal disclosures and account-policy UX` +- Type: `feature` +- Priority: `P0` +- Scope: `off-repo` +- Area: `legal-ux` +- Description: Add the legal and customer-policy surfaces required for a real + betting product. +- Acceptance criteria: + - terms of service, privacy policy, and risk disclosures exist + - required age or region gating is enforced in product UX + - rewards copy matches the approved legal posture +- Dependencies: `PROD-031` +- Source docs: `production-readiness-audit-2026-03-29.md` +- Suggested owner: `app` +- Blocker class: `launch-blocking` + +### PROD-033 Establish treasury, accounting, and financial-control workflows + +- ID: `PROD-033` +- Title: `Establish treasury, accounting, and financial-control workflows` +- Type: `ops` +- Priority: `P0` +- Scope: `off-repo` +- Area: `finance` +- Description: Define how treasury balances, fees, reserves, and payouts are + tracked and audited outside pure protocol execution. +- Acceptance criteria: + - treasury ownership and approval paths are documented + - accounting and reconciliation cadence is defined + - tax and reporting responsibilities are assigned +- Dependencies: `PROD-019`, `PROD-031` +- Source docs: `production-readiness-audit-2026-03-29.md`, + `gold-architecture-spec-plan.md` +- Suggested owner: `finance-ops` +- Blocker class: `launch-blocking` + +### PROD-034 Create customer support and dispute-resolution operations + +- ID: `PROD-034` +- Title: `Create customer support and dispute-resolution operations` +- Type: `ops` +- Priority: `P1` +- Scope: `off-repo` +- Area: `support` +- Description: Build the human process for payout disputes, delayed claims, + platform incidents, and customer communications. +- Acceptance criteria: + - support escalation path exists + - dispute-resolution workflow exists + - operator tooling can answer the questions support receives +- Dependencies: `PROD-019`, `PROD-020` +- Source docs: `production-readiness-audit-2026-03-29.md` +- Suggested owner: `ops` +- Blocker class: `quality-blocking` + +## Epic 10: Release Governance And Project Management + +### PROD-035 Convert this backlog into GitHub issues and create the production-readiness project + +- ID: `PROD-035` +- Title: `Convert this backlog into GitHub issues and create the production-readiness project` +- Type: `docs` +- Priority: `P1` +- Scope: `active` +- Runtime applicability: `cross-runtime` +- Area: `project-management` +- Description: Turn this document into the active GitHub issue board the team + will execute from after merge, using one full Kanban board with no dated + sprint contract baked into the backlog. +- Acceptance criteria: + - one GitHub Project named `Hyperbet Sprint` exists + - issue titles, fields, and epic mapping match this document + - the project uses `Status`, `Workflow`, `Priority`, `Scope`, + `Runtime Applicability`, `Work Type`, `Parent issue`, + `Sub-issues progress`, `Epic`, `Blocker Class`, `Evidence Required`, and + `Owner` fields + - `Status` exposes the live delivery queue as `Inbox`, `Needs Decision`, + `Ready`, `In Progress`, `In Review`, `In QA`, `Blocked`, and `Done` + - every epic issue has native GitHub sub-issues attached for its child + backlog tickets + - every `PROD-*` issue has the correct native GitHub parent issue + - umbrella issue bodies do not maintain manual tracked-ticket lists as a + substitute for the native hierarchy + - the project exposes `All Work`, `Launch Blockers`, `Current Queue`, + `Parked`, `Off-Repo`, and `By Runtime` views + - each issue has an owner, priority, dependency note, and runtime + applicability field or label (`evm-shared`, `svm`, `cross-runtime`, or + `wrapper-specific`) + - broad active `P0` tickets are split into child execution issues when needed + before import +- Dependencies: agreed working branch or merge target +- Source docs: `github-project-production-backlog.md` +- Suggested owner: `release` +- Blocker class: `non-blocking` + +### PROD-036 Add a go/no-go release review ritual + +- ID: `PROD-036` +- Title: `Add a go/no-go release review ritual` +- Type: `ops` +- Priority: `P1` +- Scope: `active` +- Area: `release` +- Description: Create the review ceremony that decides whether Hyperbet is + ready to promote from testnet-proven to production-ready. +- Acceptance criteria: + - signoff template exists + - required attendees and approvals are defined + - evidence checklist is attached to the ritual +- Dependencies: `PROD-025`, `PROD-031`, `PROD-033` +- Source docs: `external-audit-package-checklist.md`, + `tracking-document-map.md` +- Suggested owner: `release` +- Blocker class: `quality-blocking` + +## Epic 11: Parked And Add-Chain Epics + +### PROD-037 Preserve AVAX as a parked epic without active blocker status + +- ID: `PROD-037` +- Title: `Preserve AVAX as a parked epic without active blocker status` +- Type: `docs` +- Priority: `P1` +- Scope: `parked` +- Area: `avax` +- Description: Keep AVAX code, evidence, and investigation work intact while + ensuring AVAX incompleteness does not block the active `BSC + Solana` board. +- Acceptance criteria: + - active docs consistently mark AVAX as parked or preserved + - AVAX evidence remains linked and accessible + - AVAX does not appear in active blocker summaries unless reactivated +- Dependencies: none +- Source docs: `production-readiness-audit-2026-03-29.md`, + `tracking-document-map.md` +- Suggested owner: `release` +- Blocker class: `parked` + +### PROD-038 Define AVAX reactivation gates and re-entry checklist + +- ID: `PROD-038` +- Title: `Define AVAX reactivation gates and re-entry checklist` +- Type: `docs` +- Priority: `P2` +- Scope: `parked` +- Area: `avax` +- Description: Define the exact conditions under which AVAX can be reactivated + as an active launch lane without rediscovering prior decisions. +- Acceptance criteria: + - re-entry checklist covers registry truth, custody, proof, soak, and runtime + contract verification + - owner signoff is required before AVAX re-enters the blocker list + - active docs reference the same reactivation criteria +- Dependencies: `PROD-037` +- Source docs: `pm-launch-execution-plan.md`, + `tracking-document-map.md` +- Suggested owner: `release` +- Blocker class: `parked` + +### PROD-038A Define Base add-chain promotion checklist + +- ID: `PROD-038A` +- Title: `Define Base add-chain promotion checklist` +- Type: `docs` +- Priority: `P2` +- Scope: `parked` +- Area: `base` +- Description: Keep Base as a non-blocking add-chain lane with one explicit + re-entry checklist so the team can promote it later without reopening scope + confusion about the active launch gate. +- Acceptance criteria: + - Base promotion checklist covers registry truth, custody, staged proof, + runtime contract verification, and product-surface evidence + - Base remains excluded from active blocker summaries until an explicit + product decision promotes it + - owner signoff is required before Base becomes an active launch lane +- Dependencies: `PROD-001A` +- Source docs: `prediction-market-release-prep.md`, + `tracking-document-map.md` +- Suggested owner: `release` +- Blocker class: `parked` + +## Epic 12: Repository Governance, Review Automation, And OSS Housekeeping + +### PROD-039 Establish the repository governance baseline + +- ID: `PROD-039` +- Title: `Establish the repository governance baseline` +- Type: `docs` +- Priority: `P1` +- Scope: `active` +- Area: `repo-governance` +- Description: Add the missing repository control-plane files that make review, + ownership, disclosure, and contribution expectations explicit from day one. +- Acceptance criteria: + - `.github/CODEOWNERS` exists with clear ownership for protocol, keeper, app, + workflows, docs, and release surfaces + - `CONTRIBUTING.md` exists with branch, commit, testing, and PR expectations + - `SECURITY.md` exists with vulnerability reporting and embargo guidance + - `.github/PULL_REQUEST_TEMPLATE.md` and `.github/ISSUE_TEMPLATE/` exist +- Dependencies: owner list and escalation path +- Source docs: `tracking-document-map.md`, + `production-readiness-audit-2026-03-29.md`, + external benchmarks: `milady/.github/PULL_REQUEST_TEMPLATE.md`, + `HyperscapeAI/hyperscape/.github/CODEOWNERS` +- Suggested owner: `release` +- Blocker class: `quality-blocking` + +### PROD-040 Add a PR evidence contract and automated validation + +- ID: `PROD-040` +- Title: `Add a PR evidence contract and automated validation` +- Type: `ci` +- Priority: `P1` +- Scope: `active` +- Area: `review` +- Description: Require PRs to state what changed, why, how it was verified, and + what evidence exists, then validate that contract automatically for + launch-critical surfaces. +- Acceptance criteria: + - PR template requires `What`, `Why`, `How`, `Testing`, `Evidence`, `Risk`, + and `Rollback` + - protocol, keeper, app, and workflow PRs must include concrete evidence such + as test commands, screenshots, tx hashes, or artifact paths + - CI flags incomplete PR bodies or missing evidence for critical change areas +- Dependencies: `PROD-039` +- Source docs: `stage-a-browser-acceptance-matrix.md`, + `stage-a-promotion-execution-ledger.md`, + `external-audit-package-checklist.md`, + external benchmark: `milady/.github/PULL_REQUEST_TEMPLATE.md` +- Suggested owner: `release` +- Blocker class: `quality-blocking` + +### PROD-041 Add a safe AI-assisted PR review workflow for Hyperbet + +- ID: `PROD-041` +- Title: `Add a safe AI-assisted PR review workflow for Hyperbet` +- Type: `feature` +- Priority: `P1` +- Scope: `active` +- Area: `review-automation` +- Description: Add an AI code-review workflow that improves reviewer throughput + without turning the repo into an agent-governed system or exposing the repo + to unsafe workflow patterns. +- Acceptance criteria: + - one primary AI reviewer is selected for Hyperbet PR review automation + - the workflow is read-first, comment-only, and does not auto-close, auto-merge, + or self-approve PRs + - external-contributor safety is explicit: no unsafe `pull_request_target` + secret exposure, no broad write tokens, no unrestricted tool access + - human CODEOWNER approval remains mandatory for protocol, security, release, + and workflow changes +- Dependencies: `PROD-039`, `PROD-046` +- Source docs: `tracking-document-map.md`, + external benchmarks: `HyperscapeAI/hyperscape/.github/workflows/claude-code-review.yml`, + `milady/.github/workflows/agent-review.yml` +- Suggested owner: `infra` +- Blocker class: `quality-blocking` + +### PROD-042 Add workflow linting and agentic-action security scanning + +- ID: `PROD-042` +- Title: `Add workflow linting and agentic-action security scanning` +- Type: `security` +- Priority: `P1` +- Scope: `active` +- Area: `github-actions` +- Description: Add CI checks that specifically protect GitHub Actions and any + future AI-agent workflows from configuration drift and unsafe trigger or + permission choices. +- Acceptance criteria: + - workflow linting runs in CI for every workflow change + - workflow security scanning covers dangerous triggers, excessive + permissions, unpinned actions, and unsafe agent-tool settings + - CI fails when new workflow files violate the policy +- Dependencies: `PROD-041` +- Source docs: `production-readiness-audit-2026-03-29.md`, + external benchmarks: `milady/.github/actionlint.yaml`, + `HyperscapeAI/hyperscape/.github/workflows/security.yml` +- Suggested owner: `security` +- Blocker class: `quality-blocking` + +### PROD-043 Add repository dependency hygiene automation + +- ID: `PROD-043` +- Title: `Add repository dependency hygiene automation` +- Type: `improvement` +- Priority: `P1` +- Scope: `active` +- Area: `dependencies` +- Description: Add automated dependency update flows for npm workspaces, + GitHub Actions, and Docker surfaces so the repo does not drift into stale or + unreviewable infrastructure. +- Acceptance criteria: + - `dependabot.yml` exists and covers active package directories plus + GitHub Actions + - update groups are tuned to avoid review spam + - the review policy for automated dependency PRs is documented +- Dependencies: `PROD-039` +- Source docs: external benchmark: + `HyperscapeAI/hyperscape/.github/dependabot.yml` +- Suggested owner: `infra` +- Blocker class: `quality-blocking` + +### PROD-044 Add repo security automation beyond Solidity-specific scanning + +- ID: `PROD-044` +- Title: `Add repo security automation beyond Solidity-specific scanning` +- Type: `security` +- Priority: `P1` +- Scope: `active` +- Area: `security-automation` +- Description: Complement the current Slither and protocol gates with repo-wide + security automation for JavaScript/TypeScript, workflow surfaces, and secret + exposure. +- Acceptance criteria: + - CodeQL or equivalent static analysis runs on the active JS/TS codebase + - dependency audit runs on a schedule + - secret scanning policy is documented and automated where practical +- Dependencies: `PROD-042`, `PROD-043` +- Source docs: `production-readiness-audit-2026-03-29.md`, + external benchmark: `HyperscapeAI/hyperscape/.github/workflows/security.yml` +- Suggested owner: `security` +- Blocker class: `quality-blocking` + +### PROD-045 Add label and triage automation for PRs and issues + +- ID: `PROD-045` +- Title: `Add label and triage automation for PRs and issues` +- Type: `improvement` +- Priority: `P2` +- Scope: `active` +- Area: `triage` +- Description: Add lightweight labeling and intake automation so the repo can + route bugs, security reports, protocol changes, release work, and docs work + quickly without turning triage into manual bookkeeping. +- Acceptance criteria: + - path-based PR labels are applied automatically + - issue templates feed consistent labels for bug, security, docs, and ops + - triage labels align with the GitHub project fields +- Dependencies: `PROD-039` +- Source docs: `tracking-document-map.md`, + external benchmarks: `milady/.github/labeler.yml`, + `milady/.github/workflows/auto-label.yml` +- Suggested owner: `release` +- Blocker class: `non-blocking` + +### PROD-046 Define branch protection, required checks, and review policy + +- ID: `PROD-046` +- Title: `Define branch protection, required checks, and review policy` +- Type: `ops` +- Priority: `P1` +- Scope: `active` +- Area: `repo-governance` +- Description: Turn the desired repo-review standards into enforced GitHub + settings for `main`, `develop`, and any protected release branches. +- Acceptance criteria: + - required status checks are defined explicitly for CI, prediction-market + gates, and any new review/security workflows + - stale approvals are dismissed on new commits + - CODEOWNER review is required for sensitive paths + - merge policy is documented and enforced consistently +- Dependencies: `PROD-039`, `PROD-040`, `PROD-041` +- Source docs: `tracking-document-map.md`, + `github-project-production-backlog.md` +- Suggested owner: `release` +- Blocker class: `quality-blocking` + +## Suggested Milestones + +- `M1 - Perimeter hardening, launch truth, and governance closeout` +- `M2 - Hyperscapes integration contract and runtime recovery hardening` +- `M3 - Staged proof, soak, and audit packet` +- `M4 - Reliability, reconciliation, and production operations` +- `M5 - Compliance, launch governance, and long-tail hardening` + +## Suggested First 12 Issues To Open + +1. `PROD-021 Make keeper write auth fail closed across the active keeper surfaces` +2. `PROD-022 Remove origin-based trust from the Solana sender proxy` +3. `PROD-023 Enforce read-only allowlists and quotas on public RPC proxies` +4. `PROD-014 Version and contract-test the Hyperscapes to Hyperbet integration boundary` +5. `PROD-014A Canonicalize keeper ingestion onto the versioned Hyperscapes betting feed` +6. `PROD-014B Make feed parsing fail closed on schema drift, replay mismatch, and source resets` +7. `PROD-001 Populate canonical active launch-chain registry truth from final receipts` +8. `PROD-001A Align active launch constants and feature-flag truth with the current scope contract` +9. `PROD-009 Finish multisig, timelock, and upgrade-authority custody for the active launch chains` +10. `PROD-015B Decide and freeze the production AMM settlement model` +11. `PROD-049 Freeze the active app-shell acceptance contract for the full Hyperbet surface` +12. `PROD-047 Productionize shared wallet and account surfaces across the active runtimes` diff --git a/docs/release/launch-ops-evidence-index.md b/docs/release/launch-ops-evidence-index.md index a1636585..de036c13 100644 --- a/docs/release/launch-ops-evidence-index.md +++ b/docs/release/launch-ops-evidence-index.md @@ -1,69 +1,71 @@ # Launch Ops Evidence Index -This index ties Gates `14A`, `19`, `20`, `23`, and `24` to concrete repo -artifacts and notes what still requires live environment evidence before final -release signoff. +> **TL;DR:** The repo now contains the full phase-1 non-mainnet proving rails for `PM + perps + internal AMM` plus the surrounding app-shell and account surfaces, but the evidence bundle is still incomplete. The active production-readiness path is `Solana + BSC`; AVAX evidence is preserved but isolated and non-blocking. The remaining gaps are real staged environment provisioning, recorded Stage-A receipts, truthful active-scope registry values, frozen app-shell/account-surface evidence, governance transfer/freeze receipts, and the final audit freeze packet. -## Gate 14A: Staged Live Proof +This index ties the current launch closeout work to concrete repo artifacts and +records what still needs live evidence before release signoff. -| Surface | Path | Status | -|---|---|---| -| Proof driver | `scripts/staged-live-proof.ts` | Merged with `solana`, `bsc`, and `avax` targets. | -| Manual workflow | `.github/workflows/staged-live-proof.yml` | Supports `target=avax`. | -| Operator runbook | `docs/runbooks/staged-live-proof.md` | Updated for AVAX read-only, canary, and env-audit flow. | -| AVAX canary entrypoint | `packages/hyperbet-avax/keeper/src/staged-proof-avax.ts` | Added; mirrors BSC canary shape. | -| Expected artifact bundle | `.ci-artifacts/staged-live-proof/{summary.json,verify-chains.json,solana/*,bsc/*,avax/*}` | Pending a real staged execution with staging secrets. | +Detailed implementation work is tracked in: -## Gate 19: AVAX Canonicalization And Runtime Rails +- [GitHub Project Production Backlog](github-project-production-backlog.md) +- [Runtime Integration Readiness Matrix](runtime-integration-readiness-matrix.md) +- [Tracking Document Map](tracking-document-map.md) + +## Phase-1 Non-Mainnet Proving | Surface | Path | Status | |---|---|---| -| Shared registry schema | `packages/hyperbet-chain-registry/src/index.ts` | Governance-aware AVAX metadata fields added. | -| Shared manifest | `packages/hyperbet-deployments/contracts.json` | New governance fields present for all EVM chains. | -| AVAX package manifest | `packages/hyperbet-avax/deployments/contracts.json` | Governance fields present; production values still blank. | -| Env/runtime audit | `scripts/ci-env-audit.ts` | AVAX staging app audit now expects real staging values. | -| AVAX deploy preflight | `packages/hyperbet-avax/scripts/preflight-contract-deploy.ts` | Requires explicit governance env on mainnet deploys. | -| AVAX deploy workflows | `.github/workflows/deploy-avax-pages.yml`, `.github/workflows/deploy-avax-keeper.yml` | Repo-backed staging and production rails exist. | - -Canonical AVAX mainnet contract values are still pending committed deployment -evidence. Final Gate `19` signoff stays blocked until those values are written -into the shared registry and manifest. +| Local Stage-A runner | `scripts/run-local-stage-a.ts` | Merged; ready to orchestrate local-first bring-up once secrets and shared token addresses are present. | +| Staged proof driver | `scripts/staged-live-proof.ts` | Merged with `pm`, `perps`, and `amm` canary surfaces per chain. | +| Staged proof workflow | `.github/workflows/staged-live-proof.yml` | Merged; blocked on staged env provisioning. | +| Staged proof runbook | `docs/runbooks/staged-live-proof.md` | Updated for launch-scope proof and staged env contract. | +| Soak workflow | `.github/workflows/pm-soak.yml` | Merged; blocked on staged env provisioning and funded canary wallets. | +| Soak runbook | `docs/runbooks/pm-confidence-soak.md` | Updated for launch-scope staged soak and local-first execution. | +| Expected artifact bundle | `.ci-artifacts/staged-live-proof/{summary.json,verify-chains.json,solana/*,bsc/*,avax/*}` | Pending first real staged execution. | +| Expected soak bundle | `.ci-artifacts/pm-soak/*` | Pending first real staged execution. | -## Gate 20: Governance And Emergency Controls +## Launch-Chain Canonical Truth | Surface | Path | Status | |---|---|---| -| Oracle pause and role separation | `packages/evm-contracts/contracts/DuelOutcomeOracle.sol` | Added pauser role plus distinct reporter/finalizer/challenger surfaces. | -| Market pause controls | `packages/evm-contracts/contracts/GoldClob.sol` | Added pause surfaces for market creation and order placement. | -| Deploy wiring | `packages/evm-contracts/scripts/deploy.ts`, `packages/evm-contracts/scripts/deploy-duel-oracle.ts` | Writes timelock/multisig/emergency metadata into receipts and manifests. | -| Hardhat tests | `packages/evm-contracts/test/DuelOutcomeOracle.ts`, `packages/evm-contracts/test/GoldClob.ts` | Covers pause gating and role rotation. | -| Runbooks | `docs/runbooks/prediction-market-governance-and-emergency-controls.md`, `docs/runbooks/signer-policy-and-key-rotation.md` | Added operator evidence and signer policy guidance. | +| Shared registry schema | `packages/hyperbet-chain-registry/src/index.ts` | Full-product launch gating is merged. | +| Launch registry gate | `scripts/ci-gate-registry.ts` | Develop-side PRs now validate the Stage-A closeout registry contract (`Solana devnet + BSC testnet`, AVAX deferred); strict launch-branch runs still enforce canonical mainnet truth. | +| Canonical EVM receipt writer | `packages/evm-contracts/scripts/deployment-receipt.ts` | Merged; writes registry-shaped PM, AMM, and perps fields. | +| EVM verify script | `packages/evm-contracts/scripts/verify-deployment.ts` | Merged; full-product verification exists. | +| Solana verify script | `packages/hyperbet-solana/scripts/verify-deployment.ts` | Merged; full-product verification now includes AMM. | -Production ownership-transfer tx hashes and final role-assignment receipts are -still pending live deploy execution. +Canonical launch truth is still missing for: -## Gate 23: Launch Evidence Packaging +- `solana`: `goldAmmMarketProgramId` +- `bsc`: AMM and perps canonical fields +- `avax`: PM-core plus AMM and perps canonical fields remain preserved + follow-on work and are not blocking the active scope + +## Governance And Operational Control | Surface | Path | Status | |---|---|---| -| Release prep summary | `docs/prediction-market-release-prep.md` | Updated reviewer-facing status for AVAX launch plumbing. | -| Release memo | `docs/release/release-memo-template.md` | Converted into a candidate-ready memo scaffold with linked evidence. | -| Audit checklist | `docs/release/external-audit-package-checklist.md` | Converted into a concrete handoff checklist. | -| Deploy guide | `docs/hyperbet-production-deploy.md` | AVAX staging/prod workflow and proof expectations documented. | -| Runbook index | `docs/runbooks/README.md` | Links governance, signer, and staged-proof runbooks. | +| Governance and emergency runbook | `docs/runbooks/prediction-market-governance-and-emergency-controls.md` | Present and linked. | +| Signer and key-rotation runbook | `docs/runbooks/signer-policy-and-key-rotation.md` | Present and linked. | +| Freeze tracker | `docs/release/prediction-market-launch-freeze-tracker.md` | Current source for repo-side closeout status. | +| Release prep summary | `docs/prediction-market-release-prep.md` | Updated for launch-scope repo reality. | -## Gate 24: Audit Handoff Package +Remaining live evidence: -| Surface | Path | Status | -|---|---|---| -| ABI freeze bundle | `docs/release/abi/gold_clob.abi.json`, `docs/release/abi/duel_outcome_oracle.abi.json` | Must match the committed EVM contract surfaces. | -| Freeze manifest | `docs/release/manifests/rc-2026-03-audit-handoff-freeze.json` | Candidate freeze file; regenerate at final RC cut. | -| Existing evidence index | `docs/release/exploit-test-evidence-index.md` | Carries exploit and scenario evidence expectations. | -| Engineer inputs | `docs/release/issues/engineer-1-evm-protocol-safety.md`, `docs/release/issues/engineer-3-integration-parity.md`, `docs/release/issues/engineer-4-mm-durability.md` | Final audit bundle still depends on these lanes' artifacts. | +- ownership-transfer transaction hashes +- final freeze transactions per launch surface +- signer provisioning and key-rotation completion + +## Current Operational Blockers -## Remaining Blocking Evidence +These are blocker summaries only. Detailed owner tickets live in the canonical +backlog. -- committed AVAX mainnet canonical addresses from deployment evidence -- staged-proof read-only and canary artifacts for AVAX -- production ownership-transfer and role-assignment tx hashes -- final freeze manifest regenerated at the actual release-candidate commit +- no GitHub `staging` environment exists yet +- no `HYPERBET_*_STAGING_*` vars or secrets are provisioned yet +- local BSC AMM/perps bring-up still needs shared token addresses +- no real staged proof artifact bundle exists yet +- no real staged soak artifact bundle exists yet +- no frozen full-app acceptance bundle exists yet for wallet/account, + claims/positions, and points/referral surfaces +- no truthful launch-chain mainnet registry population exists yet diff --git a/docs/release/localnet-headless-validation-tracker.md b/docs/release/localnet-headless-validation-tracker.md new file mode 100644 index 00000000..961e6baa --- /dev/null +++ b/docs/release/localnet-headless-validation-tracker.md @@ -0,0 +1,94 @@ +# Localnet Headless Validation Tracker + +> **Historical snapshot:** This tracker preserves branch-era local runner and headless validation findings. Current open-work ownership lives in [tracking-document-map.md](tracking-document-map.md) and [github-project-production-backlog.md](github-project-production-backlog.md). Use this file as context and evidence, not as the canonical blocker list. + +> **TL;DR:** The local signoff lane is not blocked by product functionality anymore; it is blocked by local orchestration quality. The main issues are runner assumptions, headless stream proof, and final BSC/AVAX local E2E stabilization. The Hyperbet page does **not** need `?debug=1` for real local betting flow, and the integrated runner starts the stream by shelling into the sibling Hyperscapes repo and executing `bun run duel`. + +## Current Findings + +### 1. Hyperbet page debug flag + +- `?debug=1` only enables hidden E2E/operator controls in local `e2e` mode. +- It is gated behind `isE2eMode && searchParams.has("debug")` in: + - `packages/hyperbet-evm/app/src/App.tsx` + - `packages/hyperbet-bsc/app/src/App.tsx` + - `packages/hyperbet-avax/app/src/App.tsx` + - `packages/hyperbet-solana/app/src/App.tsx` +- It is not required for the actual betting surface, stream embedding, or local soak. + +### 2. How the integrated local runner starts the stream + +- `scripts/run-hyperscapes-pm-local.sh` locates the local Hyperscapes checkout, `cd`s into it, and runs: + - `bun run duel --skip-betting --skip-keeper --bots=` +- That boots the Hyperscapes duel stack and exposes: + - `GET /api/streaming/state` + - `GET /api/internal/bet-sync/state` + - `GET /api/internal/bet-sync/events` + - `http://127.0.0.1:3333/stream.html` +- Hyperbet is then started separately by the Hyperbet runner: + - local keeper on `:8080` + - local app on `:4179` + +### 3. External-drive / moved-project implication + +- The integrated runner used to assume a single default location: + - `/.worktrees/hyperscapes-stream-bet-sync` +- That is fragile if the sibling repo is moved. +- The runner now needs an explicit path contract: + - honor `HYPERSCAPES_ROOT` first + - auto-detect common sibling locations second + - fail fast with a clear message otherwise + +## Domain Tracker + +| Domain | Current State | Blocker | Next Action | Exit Gate | +| --- | --- | --- | --- | --- | +| Product UI surface | Real betting page works without `?debug=1`; debug only exposes hidden E2E controls | Default local URL still drifts toward debug/operator view in some paths | Standardize local default page to `/`, keep debug opt-in only | Local runner, monitor, and docs all default to the non-debug betting page | +| Hyperscapes bootstrap | Stream source is the sibling Hyperscapes repo launched via `bun run duel` | Runner still depends on repo-location assumptions and local runtime prerequisites | Make `HYPERSCAPES_ROOT` the explicit override and document fallback discovery | Local runner starts the duel stack from any valid checkout path without manual patching | +| Local runtime contract | Hyperscapes local lane depends on Bun, Node, Anvil, local ports, and sibling repo assets | Runtime/version drift causes brittle boots and silent failures | Pin and verify Node runtime for local duel stack, fail fast on mismatch | Local runner refuses invalid runtime combinations before partial startup | +| Headless WebGPU validation | Playwright configs now support headless WebGPU args across chains | Integrated monitor/probe lane still needs one canonical path and artifact contract | Use headless Chrome/Chromium with 1280x720 viewport and screenshot artifacts as the standard | Stream probe reports renderer-ready and writes screenshots/JSON without opening UI | +| Integrated local orchestration | `run-hyperscapes-pm-local.sh` can boot game, keeper, app, monitor, and soak lanes | Current orchestration is still awkward for scripted signoff because the runner is long-lived | Make the orchestrator spawn the integrated stack in the background and then run probe/soak against it | One local command can boot, verify, soak, archive artifacts, and tear down cleanly | +| BSC local E2E | Most restart/seed-path fixes are in | Final rerun from a clean stack still pending | Re-run headless BSC E2E and patch any remaining stale-duel or nonce issues | `ci:gate:e2e:bsc` passes headlessly from a clean environment | +| AVAX local E2E | Same codepath class as BSC, but not fully re-verified yet | Final clean-stack rerun still pending | Re-run headless AVAX E2E and patch any remaining chain-specific drift | `ci:gate:e2e:avax` passes headlessly from a clean environment | +| Soak / MM simulation | Contract gates and MM adversarial lane exist | Integrated local signoff still needs real headless proof and screenshot evidence | Run local probe, soak monitor, soak harness, and MM simulation against the same session | No unresolved sync drift, reconciliation failure, or renderer degradation | +| Documentation / operator clarity | Core runbook exists | It had drifted from actual defaults and local topology assumptions | Keep the runbook aligned with code defaults and maintain one active tracker | Operators can boot localnet without tribal knowledge or chat history | + +## Priority Order + +1. Normalize the local runner contract. + - non-interactive by default + - non-debug Hyperbet UI by default + - explicit `HYPERSCAPES_ROOT` override + - runtime verification up front + +2. Finish the headless proof lane. + - dedicated stream probe + - 16:9 screenshots + - headless WebGPU + - artifactized JSON result + +3. Re-run clean local E2E on all launch surfaces. + - Solana + - BSC + - AVAX + +4. Run the integrated local signoff suite. + - stream probe + - PM soak + - soak harness + - MM adversarial simulation + +5. Promote only after local signoff is green. + - devnet/testnet deploys + - staged proof + - staged soak + +## Tracking Rules + +- Treat this file as the active localnet tracker until devnet/testnet promotion starts. +- Every blocker should map to one of the domains above. +- Do not mark the local lane complete until: + - all three local E2E gates are green + - the headless stream probe is green + - local soak and harness are green + - no UI windows/tabs are opened by default diff --git a/docs/release/pm-launch-execution-plan.md b/docs/release/pm-launch-execution-plan.md index 12ce3963..dc1a4450 100644 --- a/docs/release/pm-launch-execution-plan.md +++ b/docs/release/pm-launch-execution-plan.md @@ -1,6 +1,8 @@ # PM Launch Execution Plan -> **TL;DR:** Testnet-first, mainnet-is-ceremony model. Phase 0 proves everything on testnets with exhaustive integration, scenario, and simulation evidence — mainnet is a mechanical replay. Phase 1 hardens AMM on the frozen PM base. Phase 2 integrates AMM with the PM stack. PR #19 is excluded from the launch-critical merge train. +> **Historical snapshot:** This document is preserved as the phase-1 superset strategy and checklist history. Current open-work ownership lives in [tracking-document-map.md](tracking-document-map.md) and [github-project-production-backlog.md](github-project-production-backlog.md). Use this file for preserved context, not as the canonical blocker list. + +> **TL;DR:** Phase-1 product scope still includes `Solana + BSC + AVAX`, with user-facing `PM/CLOB duels` and `perps/models`, plus `AMM` as an internal market-maker/liquidity engine. The active production-readiness gate, however, is now `Solana + BSC`; AVAX checklist items in this plan are preserved follow-on work and are non-blocking unless the lane is explicitly reactivated. --- @@ -9,13 +11,19 @@ ``` main └── develop - └── enoomian/pm16-17-20-21 (convergence, PR #27 → develop) - └── release/pm-gates-closeout (Phase 0) - ├── feature/pm-amm-hardening-v1 (Phase 1, after Phase 0 merges) - └── feature/pm-amm-integration-v1 (Phase 2, after Phase 1 merges) + └── audit/develop-pm-hardening ``` -**PR #19 (`feat/amm-swap-fees`)** stays open as reference. Do NOT merge into any launch-critical branch. +`audit/develop-pm-hardening` is the only active implementation branch for launch-critical closeout work. Do not split remaining launch scope across side branches unless a later release explicitly chooses to do that again. + +## Current Scope Note + +Treat this plan as a preserved superset of phase-1 work. + +- current blocking scope: `Solana + BSC` +- preserved but isolated follow-on lane: `AVAX` +- when a checklist item below mentions AVAX, it is preserved work, not a + blocker for the current signoff decision --- @@ -27,9 +35,9 @@ Every deployment, governance action, integration test, and scenario simulation i ## Phase 0 — Get PM Gates True -**Branch:** `release/pm-gates-closeout` -**Parent:** `enoomian/pm16-17-20-21` (after PR #27 merges to develop) -**Goal:** Prove the entire deployment, governance, and integration pipeline on testnets. Capture exhaustive evidence. Make mainnet deployment a mechanical ceremony. +**Branch:** `audit/develop-pm-hardening` +**Parent:** `develop` +**Goal:** Prove the entire phase-1 deployment, governance, and integration pipeline on staging/testnets for PM + AMM + perps. Capture exhaustive evidence. Make mainnet deployment a mechanical ceremony. ### Stage A — Testnet Proving Ground (Engineering) @@ -37,22 +45,28 @@ Everything in Stage A is executed by engineering on testnets with test funds. No #### WS 0.1A — Testnet Deployment -- [ ] Materialize the Stage A deploy env from [`testnet-operations-ledger.md`](/Users/mac/Desktop/hyperbet/.claude/worktrees/blissful-golick/docs/release/testnet-operations-ledger.md) through [`scripts/export-stage-a-env.sh`](/Users/mac/Desktop/hyperbet/.claude/worktrees/blissful-golick/scripts/export-stage-a-env.sh) before running testnet deployment workflows; while the workflow remains branch-only, Stage A is kicked by push on `enoomian/pm16-17-20-21` +- [ ] Materialize the Stage A deploy env from [`testnet-operations-ledger.md`](testnet-operations-ledger.md) through [`scripts/export-stage-a-env.sh`](../../scripts/export-stage-a-env.sh) before running `bun run stagea:local` or the GitHub non-mainnet workflows - [ ] Deploy `TimelockController` on BSC Testnet - [ ] Deploy `TimelockController` on AVAX Fuji - [ ] Deploy Safe multisig (3-of-3) on BSC Testnet - [ ] Deploy Safe multisig (3-of-3) on AVAX Fuji - [ ] Deploy v3 PM contracts via CREATE2 with timelock as admin on BSC Testnet - [ ] Deploy v3 PM contracts via CREATE2 with timelock as admin on AVAX Fuji +- [ ] Deploy AMM router + frozen fee config on BSC Testnet +- [ ] Deploy AMM router + frozen fee config on AVAX Fuji +- [ ] Deploy perps `SkillOracle` + `AgentPerpEngine` on BSC Testnet +- [ ] Deploy perps `SkillOracle` + `AgentPerpEngine` on AVAX Fuji - [ ] Verify CREATE2 addresses are identical across both EVM testnets -- [ ] Deploy Solana PM programs on devnet (`fight_oracle` + `gold_clob_market`; perps excluded from Stage A) -- [ ] Initialize Solana PM oracle + market config on devnet +- [ ] Deploy Solana launch programs on devnet (`fight_oracle` + `gold_clob_market` + `lvr_amm` + `gold_perps_market`) +- [ ] Initialize Solana oracle + CLOB + AMM + perps config on devnet - [ ] Transfer Solana devnet upgrade authority to test multisig - [ ] Execute `freeze_oracle_config` on Solana devnet -- [ ] Execute `freeze_config` on Solana devnet +- [ ] Execute `freeze_config` on Solana devnet for PM, AMM, and perps - [ ] Record all testnet tx hashes in evidence bundle -**Acceptance:** Both EVM testnets (BSC + AVAX) + Solana devnet deployed for PM scope. Base is out of scope for this Stage A lane. +**Acceptance:** Active-scope testnets (`BSC + Solana`) are deployed and proven +for the current signoff lane. AVAX checklist items remain preserved follow-on +work. Base is out of scope for this Stage A lane. #### WS 0.2A — Testnet Registry Population @@ -64,13 +78,14 @@ Everything in Stage A is executed by engineering on testnets with test funds. No - [ ] Verify `bun test` deployment tests pass with new values - [ ] Verify `bun x tsc --noEmit` passes for all chain apps -**Acceptance:** Registry is complete for all testnets. No blank fields. Deployment tests pass. +**Acceptance:** Registry is complete for the active testnet scope. No blank +active-scope fields. Deployment tests pass. #### WS 0.3A — Deployment Verification Script Build a script that validates a deployment is correct. Run it on testnet. Run it again on mainnet later. -- [ ] Create [`packages/evm-contracts/scripts/verify-deployment.ts`](/Users/mac/Desktop/hyperbet/.claude/worktrees/blissful-golick/packages/evm-contracts/scripts/verify-deployment.ts) that takes a chain config and checks: +- [ ] Use and extend [`packages/evm-contracts/scripts/verify-deployment.ts`](../../packages/evm-contracts/scripts/verify-deployment.ts) so it takes a chain config and checks: - [ ] Contracts deployed at expected CREATE2 addresses (`getCode` != `0x`) - [ ] Oracle constructor args match: admin, reporter, finalizer, challenger, pauser, disputeWindow - [ ] CLOB constructor args match: admin, operator, oracle, treasury, marketMaker, pauser @@ -80,7 +95,7 @@ Build a script that validates a deployment is correct. Run it on testnet. Run it - [ ] Timelock is `DEFAULT_ADMIN_ROLE` holder - [ ] Fee config matches expected snapshot values - [ ] Dispute window == 3600 (or expected value) -- [ ] Create [`packages/hyperbet-solana/scripts/verify-deployment.ts`](/Users/mac/Desktop/hyperbet/.claude/worktrees/blissful-golick/packages/hyperbet-solana/scripts/verify-deployment.ts) that checks: +- [ ] Use and extend [`packages/hyperbet-solana/scripts/verify-deployment.ts`](../../packages/hyperbet-solana/scripts/verify-deployment.ts) so it checks: - [ ] Program deployed at expected address - [ ] OracleConfig authority matches expected pubkey - [ ] OracleConfig `config_frozen == true` @@ -113,15 +128,16 @@ Build a script that validates a deployment is correct. Run it on testnet. Run it - [ ] Settlement reflects game result - [ ] Capture screenshots/recordings of each flow as evidence -**Acceptance:** Every user-facing flow works end-to-end on testnets against deployed v3 contracts with real game integration. +**Acceptance:** Every active-scope user-facing flow works end-to-end on +testnets against deployed v3 contracts with real game integration. #### WS 0.5A — Scenario Testing and Simulation Evidence - [ ] Run full CI gate suite against testnet deployments: - [ ] Solana Exploit Gate (all 6 scenarios) - [ ] EVM Exploit Gate - - [ ] Cross-Chain E2E (Solana, BSC, AVAX) - - [ ] Launch-chain runtime smoke remains green for BSC and AVAX + - [ ] Cross-Chain E2E (Solana, BSC) + - [ ] Launch-chain runtime smoke remains green for BSC - [ ] EVM Contract Proof Gate (anvil adversarial simulation) - [ ] Run market-maker adversarial simulations: - [ ] Seed corpus (all chains) @@ -145,7 +161,8 @@ Build a script that validates a deployment is correct. Run it on testnet. Run it - [ ] Verify resting orders can be reclaimed - [ ] Capture all scenario results as structured evidence artifacts -**Acceptance:** Every exploit scenario, adversarial simulation, and operational drill passes on testnets. Evidence artifacts captured and indexed. +**Acceptance:** Every active-scope exploit scenario, adversarial simulation, and +operational drill passes on testnets. Evidence artifacts captured and indexed. #### WS 0.6A — Evidence Bundle Assembly diff --git a/docs/release/prediction-market-launch-freeze-tracker.md b/docs/release/prediction-market-launch-freeze-tracker.md index b5ee0c88..946f3c0b 100644 --- a/docs/release/prediction-market-launch-freeze-tracker.md +++ b/docs/release/prediction-market-launch-freeze-tracker.md @@ -1,216 +1,118 @@ # Prediction-Market Launch Freeze Tracker -> **TL;DR:** Gates 16, 17A, 17B, 20, 21 are complete. Gate 6 (AVAX canonicalization) is blocked on deployment evidence. Gate 22 (audit packet) is not started — depends on WS3 (AVAX) completion. All code-level work is done; remaining items are ops (deploy, populate registry, attach evidence). - -Last updated: 2026-03-18 - -## How to read this tracker -- [x] = done -- [~] = in progress -- [ ] = not started -- [BLOCKED] = waiting on external evidence / missing values - -### Current position -- Working in this workspace as a single active track. -- First operational block to execute: **Priority 6 (AVAX canonicalization / rollout prep)**. -- PM-16/PM-17A/PM-17B work is intentionally gated until AVAX registry reality is locked. -- PM-21 guardrail completion is now tracked and validated on this branch. - -## Global merge order to enforce -1. Priority 6 (AVAX canonicalization + proof gating) -2. Priority 16 (resolution truth) -3. Priorities 17A and 17B (in parallel) -4. Priority 20 (governance controls) -5. Priority 21 (protocol guardrails) -6. avax/prod-proofing (post shared-contract lock for shared EVM semantics) -7. Priority 22 (audit packet / required gates) - ---- - -## [6] Priority: `avax/prod-proofing` -**Owner:** AVAX integration -**Status:** [~] - -- [x] Track file added for this branch scope. -- [x] AVAX branch work already owns: - - `packages/hyperbet-chain-registry/src/index.ts` - - `packages/hyperbet-avax/keeper/avax-fuji-bootstrap.mjs` - - `docs/runbooks/avax-fuji-bootstrap.md` - - `docs/runbooks/README.md` - - `docs/hyperbet-production-deploy.md` - - `.github/workflows/prediction-market-gates.yml` - - `.github/workflows/staged-live-proof.yml` - - `docs/prediction-market-release-prep.md` -- [x] AVAX bootstrap runbook already documents operator-only smoke path expectations (explicit env requirements, deterministic cleanup, claim skip logic, role checks). -- [x] AVAX staged-proof workflow options already include `target=avax` plus AVAX-specific artifacts and env audit. -- [BLOCKED] Canonical AVAX deployment truth in code: `packages/hyperbet-chain-registry/src/index.ts` and `packages/hyperbet-avax/deployments/contracts.json` still contain blank chain-truth values for: - - `duelOracleAddress` - - `goldClobAddress` - - `adminAddress` - - `marketOperatorAddress` - - `treasuryAddress` - - `marketMakerAddress` - - `reporterAddress` - - `finalizerAddress` - - `challengerAddress` - - `timelockAddress` - - `multisigAddress` - - `emergencyCouncilAddress` +> **TL;DR:** The repo now carries phase-1 full-product plumbing for `PM/CLOB duels + perps/models + internal AMM` plus the surrounding app shell and account surfaces, but the release train is still not deploy-only. The active production-readiness path is `Solana + BSC`; AVAX evidence is preserved but isolated and non-blocking. The remaining blockers are truthful active-scope registry population, staged environment provisioning, shared non-mainnet token/address inputs, full app-shell and account-surface closure, governance/evidence closeout, and the frozen audit packet plus external audit/remediation cycle. + +Last updated: 2026-03-25 + +Detailed implementation work is tracked in: + +- [GitHub Project Production Backlog](github-project-production-backlog.md) +- [Runtime Integration Readiness Matrix](runtime-integration-readiness-matrix.md) +- [Tracking Document Map](tracking-document-map.md) + +## Current Position + +- Active implementation train: `audit/develop-pm-hardening` +- Active production-readiness chains: `solana`, `bsc` +- Preserved but isolated follow-on lane: `avax` +- Non-blocking add-chain lane: `base` +- Implementation target: shared `EVM` runtime plus `SVM`, with `bsc` as the + current active EVM proving wrapper +- Active off-mainnet rehearsal: `solana devnet`, `bsc testnet` +- User-facing launch surfaces: + - `PM/CLOB duels` + - `perps/models` +- Internal launch-critical surface: + - `AMM` + +## Repo-Side Work Already Landed + +- PM-core hardening for oracle finality, order semantics, governance freeze, + and protocol guardrails +- repo artifact policy is enforced in CI, and the previously flagged tracked + Solana deploy artifacts are no longer present in the tracked tree +- AMM settlement implementation is materially stronger than before, but the + production settlement model still needs an explicit freeze between + oracle-driven and challenge-window paths +- Solana perps pause preserved after freeze +- Solana full-product deploy, init, freeze, and verify rails now include AMM +- canonical EVM receipt writing for PM, AMM, and perps +- launch-scope staged proof canary results for `pm`, `perps`, and `amm` +- local Stage-A runner plus staged proof and soak workflow wiring +- launch-scope registry gating with `base` removed from phase-1 blocking scope + +## Remaining Blockers + +This document keeps the blocker summary and freeze posture. Detailed execution +ownership lives in the canonical backlog. + +### 1. Canonical active-scope truth is still incomplete + +Current missing launch-truth fields: + +- active-scope launch constants still imply `avax` in places that should now + reflect the parked-chain decision +- default feature flags still understate the intended active product surface +- `solana` + - `goldAmmMarketProgramId` +- `bsc` + - `goldAmmRouterAddress` + - `mUsdTokenAddress` - `goldTokenAddress` -- [x] Conservative proof/docs posture is already visible: release + runbook docs still mark AVAX launch as pending canonical proof and proof artifacts. - -### Immediate AVAX actions completed in this workspace -- [x] Added explicit tracking entry: this document. -- [x] Updated `.github/workflows/prediction-market-gates.yml` to keep `solana` and `bsc` cross-chain lanes only while AVAX is not canonicalized (prevents AVAX from being treated as fully promoted in shared CI lanes until registry is populated). -- [ ] Populate canonical AVAX addresses and governance fields from deployment evidence (Fuji + mainnet). -- [ ] Add a second-pass tracker line in release docs and runbook index once canonicalization lands. - ---- - -## [16] Priority: `enoomian/pm-16-resolution-truth` -**Owner:** Gate 16 -**Status:** [x] - -- [x] Redesign EVM cancellation path — `cancelDuel` requires `PAUSER_ROLE`, not reporter. `reproposeResult` added for challenge resolution. -- [x] Minimum 60-second dispute window enforced on both chains (EVM constructor + Solana initialize/update). -- [x] Bootstrap fallback removed — `initialize_oracle` is one-time only (`AlreadyInitialized` on re-call). Explicit 4-param init (reporter, finalizer, challenger, dispute_window). -- [x] Invariant tests proving no settlement before terminal finalization: - - EVM: `OracleFinality.ts` (22 tests), `OracleFinality.t.sol` (21 tests), `ExploitSuite.t.sol` (10 tests) - - Solana: `oracle_invariants.ts`, `oracle-finality.test.ts` -- [x] Oracle documentation updated: - - `docs/oracle-finality-model.md` — state machine, dispute window, role matrix - - `docs/protocol/cross-chain-parity-matrix.md` — 17-feature parity comparison - -### Acceptance checkpoints -- [x] Finality is trust-minimized: propose/challenge/finalize with separate keys. -- [x] Dispute window minimum 60 seconds on both chains. -- [x] Emergency cancellation is PAUSER_ROLE only (EVM) / authority only (Solana), documented. - ---- - -## [17A] Priority: `enoomian/pm-17a-evm-order-semantics` -**Owner:** Gate 17A -**Status:** [x] - -- [x] Confirm/cement canonical order model in `packages/evm-contracts/contracts/GoldClob.sol`: - - explicit flags - - post-only rejection - - bounded matching - - STP cancel-taker behavior -- [x] Replace string revert in `claim(...)` with `NothingToClaim()`. -- [x] Expand regression coverage: - - `packages/evm-contracts/test/GoldClob.ts` - - `packages/evm-contracts/test/GoldClobSettlement.t.sol` - - `packages/evm-contracts/test/PrecisionDoS.t.sol` - - `packages/evm-contracts/test/PrecisionDoS.ts` - - `packages/evm-contracts/test/fuzz/*` -- [x] Update docs: - - `docs/enoomian-next-phase-gates.md` - - `docs/protocol/cross-chain-parity-matrix.md` - ---- - -## [17B] Priority: `enoomian/pm-17b-solana-order-semantics` -**Owner:** Gate 17B -**Status:** [x] - -- [x] Freeze Solana order semantics in `packages/hyperbet-solana/anchor/programs/gold_clob_market/src/lib.rs`: - - post-only fail on crossing - - GTC/IOC continuation rules - - `execute_matches(...)` cancel-taker STP parity -- [x] Make self-trade policy explicit in tests and docs: - - `packages/hyperbet-solana/anchor/tests/gold_clob_market.test.ts` - - `packages/hyperbet-solana/anchor/tests/black_hat_exploits.ts` - - `packages/hyperbet-solana/anchor/tests/gold_clob_security.ts` - - `docs/protocol/cross-chain-parity-matrix.md` -- [x] Lock claim parity in tests: - - cancelled => refund-only - - resolved => winner payout less MM fee - - nonterminal => revert -- [x] Add EVM/Solana differential parity cases (order flags, self-cross, claim/refund). - -## Shared PM17 parity evidence section - -Parity checks for this gate are now covered in: - -- EVM: `packages/evm-contracts/test/GoldClobSettlement.t.sol`, `packages/evm-contracts/test/fuzz/GoldClobFuzz.t.sol`, `packages/evm-contracts/test/PrecisionDoS.ts`, `packages/evm-contracts/test/PrecisionDoS.t.sol` -- Solana: `packages/hyperbet-solana/anchor/tests/gold_clob_market.test.ts`, `packages/hyperbet-solana/anchor/tests/gold_clob_security.ts` - ---- - -## [20] Priority: `enoomian/pm-20-governance-controls` -**Owner:** Gate 20 -**Status:** [x] - -- [x] Remove Solana bootstrap-authority fallbacks in both initializers — `initialize_oracle` and `initialize_config` now reject re-initialization (`AlreadyInitialized`) instead of using `init_if_needed` + default-authority bootstrap: - - `packages/hyperbet-solana/anchor/programs/fight_oracle/src/lib.rs` - - `packages/hyperbet-solana/anchor/programs/gold_clob_market/src/lib.rs` -- [x] Freeze EVM setter surface in `packages/evm-contracts/contracts/DuelOutcomeOracle.sol` and `packages/evm-contracts/contracts/GoldClob.sol`. -- [x] Freeze Solana config authority policies in `packages/hyperbet-solana/anchor/programs/fight_oracle/src/lib.rs` and `packages/hyperbet-solana/anchor/programs/gold_clob_market/src/lib.rs`. -- [x] Finalize governance docs and emergency-control stance: - - `docs/prediction-market-release-prep.md` - - `docs/hyperbet-production-deploy.md` - - privileged-surface inventory doc under `docs/release/` - -### PM20 completion evidence - -- [x] EVM governance mutators are intentionally frozen in: - - `packages/evm-contracts/contracts/DuelOutcomeOracle.sol` - - `packages/evm-contracts/contracts/GoldClob.sol` -- [x] SVM governance authority initialization and updates now require upgrade-authority - ownership + immutable config authority: - - `packages/hyperbet-solana/anchor/programs/fight_oracle/src/lib.rs` - - `packages/hyperbet-solana/anchor/programs/gold_clob_market/src/lib.rs` -- [x] Governance evidence and signature policy remain centralized in: - - `docs/runbooks/prediction-market-governance-and-emergency-controls.md` - - `docs/release/contract-privileged-surface-inventory.md` - ---- - -## [21] Priority: `enoomian/pm-21-protocol-guardrails` -**Owner:** Gate 21 -**Status:** [x] - -- [x] Add protocol-level lifecycle and guardrail enforcement in: - - `packages/evm-contracts/contracts/GoldClob.sol` - - `packages/hyperbet-solana/anchor/programs/gold_clob_market/src/lib.rs` -- [x] Verify terminal-state-only claim/invalidation semantics and open-market mutation constraints. -- [x] Add exploit/regression coverage for stale state, invalid transitions, pre-terminal claim, and market-lock manipulation: - - `packages/evm-contracts/test/ExploitSuite.t.sol` - - `packages/hyperbet-solana/anchor/tests/gold_clob_security.ts` -- [x] Confirm parity and audit-ready behavior in docs + evidence references: - - `docs/protocol/cross-chain-parity-matrix.md` - -### PM21 completion evidence -- EVM exploit regression coverage: `packages/evm-contracts/test/ExploitSuite.t.sol` -- SVM exploit regression coverage: `packages/hyperbet-solana/anchor/tests/gold_clob_security.ts` -- Settlement parity checks: - - `packages/evm-contracts/test/GoldClobSettlement.t.sol` - - `packages/hyperbet-solana/anchor/tests/gold_clob_market.test.ts` - ---- - -## [22] Priority: `enoomian/pm-22-required-gates-and-audit-packet` -**Owner:** Gate 22 -**Status:** [ ] - -- [ ] Finalize required-gate execution artifact and required checks: - - `docs/release/gate-22-required-check-contract.md` - - `docs/protocol/cross-chain-parity-matrix.md` - - `docs/prediction-market-release-prep.md` - - `docs/enoomian-next-phase-gates.md` - - `.github/workflows/prediction-market-gates.yml` - - `.github/workflows/staged-live-proof.yml` -- [ ] Assemble audit packet under `docs/release/`: - - privileged surface inventory - - required checks and gate lock status - - staged-proof evidence checklists - - residual-risk register -- [ ] Ensure doc+workflow truth is aligned and no longer contradicted by implementation. - ---- - -## Definition of done for this tracker -- [ ] A PR is not allowed to merge until its priority block is fully checked. -- [ ] Each completed file-level task is linked back to a test or proof artifact. -- [ ] Release-facing docs and CI/lane promotion are mutually consistent before `priority 22` begins. + - `skillOracleAddress` + - `perpEngineAddress` +- `avax` + - PM-core canonical fields + - AMM canonical fields + - perps canonical fields + - governance and operator addresses from final receipts + - preserved follow-on work; not blocking the current production-readiness path + +### 2. Staged proof and staged soak are blocked on provisioning + +Audited GitHub state as of 2026-03-25: + +- deploy and testnet repo secrets exist +- no GitHub `staging` environment exists +- no `HYPERBET_*_STAGING_*` vars or secrets are provisioned + +That means staged proof and soak are structurally ready but operationally +blocked. + +### 3. Local Stage-A is waiting on shared token and address inputs + +Still missing for honest active-scope BSC AMM/perps rehearsal: + +- `BSC_TESTNET_MUSD_TOKEN_ADDRESS` +- `BSC_TESTNET_GOLD_TOKEN_ADDRESS` +- optional perps margin token addresses when margin is not the GOLD token + +### 4. Governance and audit evidence are still open + +Still required: + +- governance transfer receipts +- freeze receipts +- signer and key-rotation closeout +- staged proof artifact bundle +- staged soak artifact bundle +- RC freeze manifest +- external audit and remediation outputs + +### 5. AMM settlement truth, coordinated full-product smoke, and app/account surface closure are still open + +Still required: + +- explicit production AMM settlement-model freeze +- audit-grade explanation or tightening of the Solana AMM settlement account + posture +- one coordinated staged smoke and evidence bundle for the enabled full-product + surfaces +- one frozen product contract for wallet/account, claims/positions, and + points/referral surfaces on the active runtimes + +## Ordered Next Steps + +The ordered next steps are now owned by the canonical backlog and runtime +matrix. Use this document to verify that freeze posture and blocker summaries +still match those sources. diff --git a/docs/release/production-readiness-audit-2026-03-29.md b/docs/release/production-readiness-audit-2026-03-29.md new file mode 100644 index 00000000..6f46a8b5 --- /dev/null +++ b/docs/release/production-readiness-audit-2026-03-29.md @@ -0,0 +1,453 @@ +# Hyperbet Production Readiness Audit - 2026-03-29 + +> **TL;DR:** Hyperbet is materially stronger and already proves real non-mainnet browser-to-chain betting flows, but it is not yet production-ready for a real-money Hyperscapes betting launch. The biggest remaining gaps are keeper and public-API perimeter hardening, a pinned and consistently enforced `Hyperscapes -> Hyperbet` integration contract, canonical launch-chain registry truth, staged and production environment provisioning, shared app-shell and account-surface closure, governance custody and freeze evidence, durable keeper operations, external audit package completion, and betting-product operational controls that do not yet exist in the repo. + +## Purpose + +This document is the production-readiness audit snapshot for the Hyperbet repo as +of `2026-03-29`. + +It answers two questions: + +1. what is already strong enough to keep +2. what still has to be built, proven, or operated before Hyperbet can be + treated as a production betting product for Hyperscapes + +This audit covers: + +- code and release artifacts in this repo +- repo-adjacent operational work explicitly referenced by the repo +- betting-product workstreams that are not yet represented in code but are + required for a real production launch + +This audit does **not** redefine product scope, but it does make the current +production-readiness gate explicit: + +- active production-readiness scope is `Solana` and `BSC` +- `AVAX` remains a preserved but isolated lane; existing work and evidence stay + in place, but AVAX-specific gaps do not block the current readiness decision +- `Base` remains a non-blocking add-chain lane +- implementation target remains runtime-family based: shared `EVM` plus `SVM`, + with `BSC` serving as the current active EVM wrapper rather than the + long-term exclusive EVM chain target +- user-facing surfaces remain `PM/CLOB duels` and `perps/models` +- `AMM` remains an internal market-making surface, not a retail browser surface + +Detailed execution ownership now lives in: + +- [GitHub Project Production Backlog](github-project-production-backlog.md) +- [Runtime Integration Readiness Matrix](runtime-integration-readiness-matrix.md) +- [Tracking Document Map](tracking-document-map.md) + +## Evidence Base + +Primary source documents for this audit: + +- [Tracking Document Map](tracking-document-map.md) +- [Runtime Integration Readiness Matrix](runtime-integration-readiness-matrix.md) +- [Prediction Market Release Prep](../prediction-market-release-prep.md) +- [Launch Ops Evidence Index](launch-ops-evidence-index.md) +- [Prediction-Market Launch Freeze Tracker](prediction-market-launch-freeze-tracker.md) +- [Stage-A Browser Acceptance Matrix](stage-a-browser-acceptance-matrix.md) +- [Residual Risk Register](residual-risk-register.md) +- [Threat Model](threat-model.md) +- [PM Launch Execution Plan](pm-launch-execution-plan.md) +- [External Audit Package Checklist](external-audit-package-checklist.md) +- [Hyperbet Production Deploy](../hyperbet-production-deploy.md) +- [Cross-Chain Parity Matrix](../protocol/cross-chain-parity-matrix.md) +- [Perps EVM/SVM Parity Matrix](../perps-parity-matrix.md) +- [Hyperbet System Design Alignment](../system-design-alignment.md) +- [Contract Privileged Surface Inventory](contract-privileged-surface-inventory.md) +- [Hyperscapes Local PM Integration](../runbooks/hyperscapes-local-pm-integration.md) +- [Prediction-Market Test Flow](../runbooks/prediction-market-test-flow.md) + +The board-ready follow-on backlog lives in +[GitHub Project Production Backlog](github-project-production-backlog.md). + +## Canonical Tracking Contract + +- this document owns the production-readiness verdict and blocker inventory +- the backlog owns detailed implementation work +- the runtime matrix owns the answer to “is the end-to-end loop actually + running?” +- historical ledgers and branch-era plans preserve evidence, but do not own + current blockers + +## Executive Summary + +### What is already true + +- Stage-A non-mainnet proving is real, not paper-only. +- Direct protocol canaries exist across the phase-1 product surfaces. +- Browser-to-chain acceptance exists against real deployed Stage-A chains. +- The real Hyperscapes duel lane has been proven locally and is documented. +- Repo artifact policy is enforced in CI, and the previously flagged tracked + Solana deploy artifacts are no longer present in the tracked tree. +- Governance freeze and pause controls are materially stronger than the earlier + convergence baseline. +- The repo now contains credible release, ops, threat, and audit-prep + documentation. +- Existing AVAX deployments and investigation work are preserved, but AVAX is + no longer on the current production-critical path. + +### What is still blocking production readiness + +- Keeper and public-API auth hardening is incomplete and contains verified + launch-blocking gaps. +- The `Hyperscapes -> Hyperbet` feed contract is only partially versioned and is + enforced asymmetrically across keeper variants. +- Active launch-chain registry truth is incomplete for `Solana` and `BSC`, and + the active-scope constants and default feature truth still drift from the + current `BSC + Solana` contract; + AVAX registry completion remains non-blocking while the chain is isolated. +- GitHub staged environment and staged proof/soak provisioning are incomplete. +- Governance custody, ownership transfer, upgrade-authority transfer, and final + freeze evidence are incomplete. +- Shared wallet/account surfaces, points/referrals product claims, and full + app-shell acceptance are not yet frozen as one launch-grade contract. +- The AMM settlement model and coordinated full-product staged smoke are not + yet frozen to one audit-grade truth. +- Keeper persistence, observability, alerting, reconciliation, and rollback + posture are not yet production-grade. +- The external audit packet is not complete and a remediation closeout loop has + not been demonstrated from a frozen RC. +- The repo does not yet cover the full legal, compliance, treasury, support, + and incident-management obligations of a real betting product. + +### Overall assessment + +Current maturity: **Moderate with launch-blocking perimeter and integration flaws** + +Release posture: **strong testnet acceptance, not yet production-ready** + +## Current Green State + +The following areas should be treated as already landed and should not be +re-opened without a concrete regression: + +- Stage-A browser acceptance for the current branch scope +- real-duel local Hyperscapes integration lane +- direct canary and verification rails +- launch-scope release docs and freeze tracking +- EVM governance freeze posture +- Solana freeze and pause posture +- explicit distinction between browser surfaces and internal AMM operations + +## AVAX Scope Decision + +AVAX is not being undone, removed, or denied as future scope. The current +decision is narrower: + +- keep existing AVAX code, deployments, and evidence intact +- stop treating AVAX incompleteness as a blocker for the current + production-readiness path +- do not open new AVAX-specific critical-path work unless the lane is + intentionally reactivated + +## Security Findings Snapshot + +The following findings were verified directly against the current repo snapshot +and should be treated as production blockers, not abstract “audit later” work. + +| Severity | Finding | Evidence | Production impact | +|---|---|---|---| +| Critical | BSC, Solana, and AVAX keepers fail open when `ARENA_WRITE_KEY` is unset. | `requireWriteAuth()` returns `true` on missing key in `packages/hyperbet-bsc/keeper/src/service.ts`, `packages/hyperbet-solana/keeper/src/service.ts`, and `packages/hyperbet-avax/keeper/src/service.ts`; the canonical EVM keeper already rejects this state in `packages/hyperbet-evm/keeper/src/service.ts`. | Unauthorized callers can reach write paths such as invite redeem, wallet link, external bet recording fallback, and stream publish if deployment envs omit the key. This is an active blocker for `BSC` and `Solana`; AVAX remains isolated and therefore non-blocking in the current launch gate. | +| High | The Solana sender proxy treats browser `Origin` as authorization. | `handleSolanaSenderProxy()` in `packages/hyperbet-solana/keeper/src/service.ts` allows requests from any allowed origin even without the write key. | A forged client can relay whitelisted Hyperbet Solana transactions through the keyed sender proxy, turning the keeper into an open relay. | +| High | Public RPC proxies do not enforce a read-only method allowlist. | `handleSolanaRpcProxy()` and `handleEvmRpcProxy()` in the keeper services validate JSON-RPC shape and cache TTLs, but do not restrict forwarded methods. | Public clients can consume keyed upstream quota with arbitrary JSON-RPC methods and widen the externally reachable provider surface beyond the documented “read-only proxy” contract. | +| High | Most keepers still trust the legacy raw Hyperscapes stream instead of the newer versioned betting-feed contract. | The local EVM runner wires `BET_SYNC_SOURCE_STATE_URL` and `BET_SYNC_SOURCE_EVENTS_URL` to `/api/internal/bet-sync/*` in `scripts/run-hyperscapes-pm-local.sh`, and Hyperscapes emits `schemaVersion`, `sourceEpoch`, `seq`, `phaseVersion`, and `rendererHealth` from `packages/server/src/routes/streaming-betting-routes.ts`; meanwhile BSC, Solana, and AVAX still ingest `/api/streaming/state` through permissive `toStreamState()` parsers in their keeper services. | Upstream feed drift, replay/reset events, or source-epoch changes can degrade into silent keeper desync instead of a fail-closed integration error. This is launch-blocking for the active `BSC` and `Solana` scope; AVAX remains a preserved follow-on lane. | + +## Maturity Scorecard + +| Category | Rating | Notes | +|---|---|---| +| Arithmetic safety | Moderate | Strong parity and guardrail work exists, but precision and edge-case follow-through is still tracked in hardening plans and exploit coverage. | +| Auditing and observability | Weak | Audit-prep docs exist, but staged evidence, production telemetry, alerting, and incident response automation are incomplete. | +| Authentication and access control | Weak | Governance freeze is materially improved, but live custody transfer is incomplete and the keeper perimeter still has verified fail-open write auth and proxy-trust gaps. | +| Complexity management | Moderate | The architecture direction is clear, but BSC/AVAX wrappers, keeper boundaries, and the Hyperscapes feed contract are still converging toward one canonical runtime contract. AVAX is currently isolated from the active delivery path rather than treated as a gate. | +| Decentralization and custody | Weak | Production governance posture still depends on finishing multisig/timelock rollout and final registry-backed custody truth. | +| Documentation | Satisfactory | Release, threat, parity, and runbook coverage is strong, but production checklists still point to unfinished evidence and freeze outputs. | +| Transaction-ordering risk | Moderate | Risks are understood and documented, but they are largely accepted-risk mitigations rather than full protections. | +| Low-level and unsafe primitives | Satisfactory | No broad low-level red flags dominate the current codebase, but some generated and operational edges still need cleanup. | +| Testing and verification | Moderate | Non-mainnet proving is unusually strong, but staged proof, staged soak, frozen audit packet, and some adversarial gaps remain open. | + +## Detailed Gap Inventory + +### 1. Keeper and API perimeter hardening is incomplete + +The current repo snapshot still exposes internet-facing keeper behavior that is +not safe for production. + +Concrete gaps verified in code: + +- BSC, Solana, and AVAX keepers return `true` from `requireWriteAuth()` when + `ARENA_WRITE_KEY` is unset, unlike the EVM keeper which already fails closed +- write routes protected by that helper include invite redeem, wallet linking, + external bet recording fallback, and stream publish +- the Solana sender proxy currently accepts either a privileged write key or a + trusted `Origin` header, which is not a trustworthy authentication boundary +- public Solana and EVM RPC proxies are described as public keyed read-only + proxies in env docs, but the code does not enforce a read-only method set + +Why this matters: + +- production keepers should never become writable because an env var is missing +- proxy trust based on `Origin` is not a secure server-to-server control +- keyed public RPC surfaces need explicit method and quota boundaries, not just + operator intent + +### 2. Active launch-chain canonical truth is not complete + +The repo still does not contain truthful launch-chain registry values for all +active production surfaces. + +Concrete gaps called out by the release docs: + +- `BETTING_LAUNCH_EVM_CHAIN_ORDER` still implies `avax` instead of the parked + chain posture +- `DEFAULT_FEATURE_FLAGS` still default `perps: false` and `amm: false` even + though active launch docs describe a broader product destination +- `solana` missing canonical `goldAmmMarketProgramId` +- `bsc` missing canonical AMM and perps fields +- `avax` still has incomplete PM, AMM, perps, and governance/operator fields, + but those gaps are currently isolated and non-blocking + +Why this matters: + +- the chain registry is the canonical runtime source of truth +- production deploys and release docs cannot honestly promote without it +- SDK, app builds, and operational tooling cannot stabilize around placeholders + for the active `BSC`/`Solana` launch path + +### 3. Staged and production environment provisioning is incomplete + +The repo already has staged proof and soak rails, but the live environment +contract is not actually provisioned. + +Repo-evidenced gaps: + +- no GitHub `staging` environment +- no `HYPERBET_*_STAGING_*` vars or secrets +- no real staged proof bundle +- no real staged soak bundle + +Why this matters: + +- testnet-first is the release model in this repo +- without staged proof and soak, mainnet remains ceremony-only on paper + +### 4. Governance custody and freeze closeout is incomplete + +The code-level freeze posture is stronger than before, but production custody is +not complete until the live authorities are actually transferred and frozen. + +Repo-evidenced gaps: + +- ownership transfer receipts +- final freeze receipts +- signer provisioning completion +- key-rotation closeout for historical deploy keys +- Solana upgrade-authority transfer evidence + +Why this matters: + +- a frozen governance model is only real once receipts exist +- the threat model assumes multisig and timelock custody, not ad hoc key + handling + +### 5. The Hyperscapes integration boundary is only partially formalized + +The upstream game-to-betting contract has evolved, but Hyperbet still consumes +it asymmetrically. + +Repo-evidenced gaps: + +- Hyperscapes now exposes an authenticated internal betting feed at + `/api/internal/bet-sync/state` and `/api/internal/bet-sync/events` +- that feed carries explicit `schemaVersion`, `sourceEpoch`, `seq`, + `phaseVersion`, and `rendererHealth` fields plus replay/reset semantics +- the local EVM runner already wires the keeper to that richer feed contract +- the current local real-duel evidence resolves against the sibling + `hyperscapes-main-latest-e2e` checkout at commit `4bb8987dc`, but that target + is not yet recorded as a formal Hyperbet release dependency +- the canonical EVM keeper consumes and tracks the richer feed via + `packages/hyperbet-evm/keeper/src/betSync.ts` +- BSC, Solana, and AVAX keepers still poll the legacy + `/api/streaming/state` surface and accept any object with a `cycle` +- those raw-stream parsers silently synthesize missing `seq` and `emittedAt` + instead of rejecting incompatible or degraded payloads +- the current soak and proof posture still leans on state polling as the + primary verification lane rather than treating direct event-feed consumption, + persisted checkpoints, and idempotent replay as the canonical release proof +- the local Hyperscapes integration runbook still describes + `/api/streaming/state` as the primary keeper contract even though the local + runner already prefers the internal bet-sync contract for EVM + +Why this matters: + +- launch releases need one pinned upstream contract, not multiple implied ones +- replay/reset and source-epoch transitions are part of correctness, not only + developer convenience +- permissive parsing hides integration regressions until market state has + already drifted + +### 6. Runtime and product architecture are still converging + +The intended design is one shared product with chain adapters, but some package +layout and keeper responsibilities are still transitional. + +Repo-evidenced gaps: + +- BSC remains the active EVM launch wrapper and AVAX remains a preserved but + isolated wrapper +- canonical EVM runtime convergence is not complete +- several active tickets and docs previously used `BSC` as shorthand for the + active EVM lane, which can cause negative work unless issue conversion keeps + the shared-EVM implementation contract explicit +- SDK surfaces are not yet locked to canonical registry truth +- public chain/runtime contracts between Hyperbet and Hyperscapes are not yet + packaged as a versioned release contract +- the EVM AMM still exposes both challenge-window settlement and oracle-driven + settlement, so production AMM settlement truth is not frozen +- PM has the strongest live browser-backed evidence today; coordinated + full-product staged smoke and evidence parity for the remaining active + surfaces are still open +- wallet/account surfaces and rewards surfaces are still stronger as proven + features than as frozen product contracts with durable support expectations + +Why this matters: + +- production incidents get harder to reason about when each chain drifts +- duplicated wrapper logic increases maintenance and review cost +- auditors and operators should not have to infer AMM settlement truth from + contradictory implementation and documentation + +### 7. Keeper durability and operational reliability are not complete + +The deployment guide is explicit that keeper SQLite is ephemeral unless the +state backend is changed or persistence is attached. + +Repo-evidenced gaps: + +- durable keeper storage strategy +- backup and restore runbooks +- production observability dashboards and alert thresholds +- on-call escalation and incident evidence packaging +- reconciliation tooling for claims, points, referrals, and operator review + +Why this matters: + +- a betting product needs durable audit trails and recovery paths +- real customer support depends on deterministic reconstruction of market and + payout state + +### 8. Security verification is not complete + +The repo has strong exploit and parity work, but the audit packet is still not +frozen, not all explicit test gaps are closed, and the keeper/API perimeter +issues above need remediation before an external audit closeout can honestly be +treated as final. + +Repo-evidenced gaps: + +- missing explicit reentrancy exploit test tracking item +- frozen external audit packet still incomplete +- staged proof and soak evidence missing from the audit package +- final findings ledger and RC freeze manifest still open + +Why this matters: + +- external audit handoff cannot be honest without frozen artifacts +- accepted risk needs explicit owner signoff and expiry, not just a document + +### 9. Gold asset semantics are still a phase-separation gap + +The repo is explicit that phase-1 does not yet have a fully solved cross-chain +Gold architecture. + +Repo-evidenced gaps: + +- canonical Gold source-of-truth decision +- issuance and redemption model +- cross-chain representation model +- reserve and reconciliation invariants +- EVM Gold alias cleanup after the architecture decision + +Why this matters: + +- a production betting application cannot make stronger Gold claims than the + implemented asset model can support + +### 10. Betting-product operational controls are still missing + +The repo documents operator runbooks but does not yet represent the full +commercial launch obligations of a real betting application. + +Operational requirements inferred from the product category: + +- jurisdiction and licensing review +- KYC/AML posture +- age gating and responsible-gaming controls +- terms of service, privacy, and risk disclosures +- treasury, accounting, and tax operations +- customer support, dispute handling, and incident communications + +Why this matters: + +- these are launch-critical for a real-money betting product even when the repo + itself does not yet implement them + +## Launch Readiness Verdict + +### Ready today + +- continued non-mainnet proving +- internal review and audit-prep work +- turning the current branch evidence into an executable project backlog + +### Not ready today + +- production promotion +- internet-exposed keeper deployments with the current perimeter posture +- public claims of mainnet launch readiness +- external audit handoff as a final frozen packet +- real-money betting launch operations + +## Recommended Execution Order + +1. close keeper and public-API auth hardening blockers +2. formalize and harden the Hyperscapes integration boundary across the active + keeper surfaces, promote direct event-feed proof, and keep AVAX isolated +3. close active launch-chain registry truth, active-scope constants, feature + truth, and governance custody for `BSC` and `Solana` +4. freeze the AMM settlement model and close the remaining coordinated + full-product smoke and evidence gaps +5. provision staged environment and capture truthful staged proof/soak evidence +6. freeze the shared app-shell, wallet/account, and rewards product contract + for the active runtimes +7. harden keeper durability, observability, and reconciliation +8. freeze and deliver the external audit packet, then remediate findings +9. finish repo-adjacent production controls: legal, compliance, treasury, and + support +10. only then treat mainnet as a release ceremony + +## Recommended GitHub Project Shape + +Use a single production-readiness project with epics grouped by: + +- chain truth and deploy integrity +- staged and production environment readiness +- governance and key management +- Hyperscapes integration and runtime convergence +- application shell, account surfaces, and rewards +- reliability, observability, and reconciliation +- security hardening and external audit +- Gold asset architecture +- compliance, treasury, support, and launch operations +- repository governance and review automation + +Use one full Kanban board with no dated sprint contract. Sequence work by +status, priority, dependencies, scope, runtime applicability, and blocker +class. + +The issue-ready seed list for that project is in +[GitHub Project Production Backlog](github-project-production-backlog.md). diff --git a/docs/release/release-memo-template.md b/docs/release/release-memo-template.md index ac314cfe..323d773f 100644 --- a/docs/release/release-memo-template.md +++ b/docs/release/release-memo-template.md @@ -1,68 +1,59 @@ -# Release Memo: RC Audit Handoff Candidate +# Release Memo: Phase-1 RC Candidate -This memo is the candidate-ready release summary for the prediction-market -launch package as of March 13, 2026. It is intentionally written against -concrete repo artifacts instead of blank placeholders. +> **TL;DR:** This memo tracks the current release-candidate posture for the active `Solana + BSC` production-readiness gate, with `AVAX` preserved as an isolated follow-on lane. The repo now has full-product non-mainnet rails for `PM/CLOB duels + perps/models + internal AMM`, but launch is still blocked on canonical active-scope truth, staged environment provisioning, governance/evidence receipts, coordinated full-product smoke, and the external audit/remediation cycle. ## Release Candidate -- Candidate label: `rc-2026-03-audit-handoff` -- Freeze manifest: [manifests/rc-2026-03-audit-handoff-freeze.json](manifests/rc-2026-03-audit-handoff-freeze.json) +- Candidate label: `rc-2026-03-phase1-launch` +- Active closeout branch: `audit/develop-pm-hardening` - Release prep summary: [../prediction-market-release-prep.md](../prediction-market-release-prep.md) - Launch-ops evidence index: [launch-ops-evidence-index.md](launch-ops-evidence-index.md) -- Release owner: fill at RC freeze - -## Launch Scope - -- Launch chains: Solana, BSC, AVAX -- Current constraint: AVAX production rollout remains blocked until canonical - registry values and staged-proof artifacts are attached. -- Required evidence gates for this lane: `14A`, `19`, `20`, `23`, `24` - -## Gate Summary - -- Gate `14A`: proof rail is implemented in - [`scripts/staged-live-proof.ts`](../../scripts/staged-live-proof.ts) with - workflow support in - [../../.github/workflows/staged-live-proof.yml](../../.github/workflows/staged-live-proof.yml). - Real staged artifacts are still pending. -- Gate `19`: AVAX runtime/deploy plumbing is wired through the shared registry, - manifests, deploy preflight, env audit, and AVAX deploy workflows. Canonical - mainnet addresses are still pending committed deployment evidence. -- Gate `20`: EVM governance and emergency controls are implemented in - [../../packages/evm-contracts/contracts/DuelOutcomeOracle.sol](../../packages/evm-contracts/contracts/DuelOutcomeOracle.sol) - and - [../../packages/evm-contracts/contracts/GoldClob.sol](../../packages/evm-contracts/contracts/GoldClob.sol), - with operator guidance in - [../runbooks/prediction-market-governance-and-emergency-controls.md](../runbooks/prediction-market-governance-and-emergency-controls.md) - and - [../runbooks/signer-policy-and-key-rotation.md](../runbooks/signer-policy-and-key-rotation.md). -- Gate `23`: release-facing deploy, runbook, memo, and checklist docs are now - linked and candidate-ready. -- Gate `24`: ABI freeze files and the audit package scaffold exist, but final - handoff still depends on Engineer `1`, `3`, and `4` artifacts plus the live - Gate `14A` bundle. +- Freeze tracker: [prediction-market-launch-freeze-tracker.md](prediction-market-launch-freeze-tracker.md) + +## Product Scope + +- Active production-readiness chains: `Solana`, `BSC` +- Preserved but isolated follow-on lane: `AVAX` +- Non-blocking add-chain lane: `Base` +- User-facing launch surfaces: + - `PM/CLOB duels` + - `perps/models` +- Internal launch-critical surface: + - `AMM` as headless MM and liquidity engine + +## Repo Snapshot + +- PM-core hardening is merged. +- AMM settlement implementation is materially stronger than before, but the + production settlement model still needs an explicit freeze. +- Solana perps pause survives config freeze. +- Solana full-product deploy, init, freeze, and verify paths include `lvr_amm`. +- EVM deploy receipts and verification now cover PM, AMM, and perps. +- Staged proof and soak rails now target launch-scope `pm`, `perps`, and `amm` + surfaces. + +## Blocking Items + +- active-scope launch constants, feature truth, and canonical registry fields + are still incomplete +- GitHub staged environment vars and secrets are not provisioned yet +- shared BSC testnet token addresses are still missing for local AMM/perps + rehearsal +- governance transfer and freeze receipts are still pending +- coordinated full-product staged smoke and evidence bundle are still pending +- final audit packet, external audit, and remediation are still pending ## Evidence Links -- Deploy guide: [../hyperbet-production-deploy.md](../hyperbet-production-deploy.md) -- Staged proof runbook: [../runbooks/staged-live-proof.md](../runbooks/staged-live-proof.md) -- Governance runbook: - [../runbooks/prediction-market-governance-and-emergency-controls.md](../runbooks/prediction-market-governance-and-emergency-controls.md) -- Signer rotation runbook: - [../runbooks/signer-policy-and-key-rotation.md](../runbooks/signer-policy-and-key-rotation.md) -- Audit checklist: [external-audit-package-checklist.md](external-audit-package-checklist.md) -- Existing exploit/test evidence index: - [exploit-test-evidence-index.md](exploit-test-evidence-index.md) +- [Production deploy guide](../hyperbet-production-deploy.md) +- [Staged proof runbook](../runbooks/staged-live-proof.md) +- [Soak runbook](../runbooks/pm-confidence-soak.md) +- [Testnet operations ledger](testnet-operations-ledger.md) +- [External audit checklist](external-audit-package-checklist.md) -## Launch Decision Snapshot +## Current Decision - Current decision: not ready for unrestricted real-funds launch -- Blocking items: - - commit canonical AVAX mainnet registry values from deployment evidence - - complete effective AVAX wallet setup for governance and operator roles - - capture AVAX staged read-only and canary artifacts - - capture production timelock/multisig/emergency ownership transfer evidence - - merge final audit outputs from Engineers `1`, `3`, and `4` -- Accepted residual-risk discussion can begin only after the blockers above are - closed and the freeze manifest is regenerated at the RC commit. +- Next honest milestone: complete local Stage-A deploy and verify, provision the + staged environment, capture launch-scope staged proof and soak artifacts, and + then populate launch-chain canonical mainnet truth from final receipts diff --git a/docs/release/runtime-integration-readiness-matrix.md b/docs/release/runtime-integration-readiness-matrix.md new file mode 100644 index 00000000..8acd9d34 --- /dev/null +++ b/docs/release/runtime-integration-readiness-matrix.md @@ -0,0 +1,59 @@ +# Runtime Integration Readiness Matrix + +> **TL;DR:** This matrix is the canonical end-to-end runtime status view for Hyperbet's active production-readiness gate. It answers whether `Hyperscapes emits duel -> Hyperbet ingests stream -> Hyperbet writes on-chain -> Hyperbet discovers result -> Hyperbet resolves/claims -> Hyperbet recovers after restart` is actually working for `BSC + Solana`. `AVAX` is preserved as a parked follow-on lane and is intentionally omitted from the active blocker table. The `BSC` rows represent the current active EVM wrapper, not a BSC-only implementation target. + +## Scope And Legend + +- active gate: `BSC + Solana` +- parked lane: `AVAX` +- implementation note: `BSC` rows represent the active EVM proving wrapper; + shared EVM work should land in canonical EVM paths unless explicitly + wrapper-specific +- statuses: + - `Green in Stage-A`: proven locally against deployed Stage-A chains + - `Partial`: working evidence exists, but production-grade hardening or canonicalization is still open + - `Blocked`: not yet production-ready for the active launch gate + +Primary evidence documents: + +- [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md) +- [stage-a-promotion-execution-ledger.md](stage-a-promotion-execution-ledger.md) +- [launch-ops-evidence-index.md](launch-ops-evidence-index.md) + +Open work ownership lives in: + +- [github-project-production-backlog.md](github-project-production-backlog.md) + +## Active Runtime Loops + +| Chain | Runtime loop | Current status | Evidence | Owner doc / ticket | Active blocker | Scope note | +|---|---|---|---|---|---|---| +| `BSC` | Duel discovery / stream ingestion | Partial | [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md), [../runbooks/hyperscapes-local-pm-integration.md](../runbooks/hyperscapes-local-pm-integration.md) | `github-project-production-backlog.md` / `PROD-014`, `PROD-014A`, `PROD-014B`, `PROD-018A` | Active BSC keeper still needs a single canonical versioned feed contract with fail-closed parsing, durable checkpoints, and event-feed proof | Active | +| `BSC` | Market materialization | Green in Stage-A | [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md), [stage-a-promotion-execution-ledger.md](stage-a-promotion-execution-ledger.md) | `github-project-production-backlog.md` / `PROD-001`, `PROD-006` | Canonical launch registry truth and staged proof artifacts still need to be frozen | Active | +| `BSC` | Trade posting | Green in Stage-A | [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md) | `github-project-production-backlog.md` / `PROD-006`, `PROD-007A` | Needs coordinated staged smoke evidence bundle and production telemetry | Active | +| `BSC` | Result discovery / correlation | Partial | [stage-a-promotion-execution-ledger.md](stage-a-promotion-execution-ledger.md) | `github-project-production-backlog.md` / `PROD-014B`, `PROD-019` | Result correlation and operator reconciliation are not yet productionized | Active | +| `BSC` | Resolve / claim / refund | Green in Stage-A | [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md) | `github-project-production-backlog.md` / `PROD-010`, `PROD-025` | Governance freeze receipts and audit-packet evidence still need to be attached | Active | +| `BSC` | Perps / models lifecycle | Partial | [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md), [../prediction-market-release-prep.md](../prediction-market-release-prep.md) | `github-project-production-backlog.md` / `PROD-015`, `PROD-001`, `PROD-007A` | Final BSC product claim, canonical perps addresses, and coordinated staged browser evidence are still open | Active | +| `BSC` | Wallet / account shell | Partial | [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md), [../prediction-market-release-prep.md](../prediction-market-release-prep.md) | `github-project-production-backlog.md` / `PROD-047`, `PROD-049` | Shared wallet/account behavior, claims/positions shell truth, and full-app acceptance are not yet frozen as one canonical launch contract | Active | +| `BSC` | Points / referrals / rewards | Partial | [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md), [production-readiness-audit-2026-03-29.md](production-readiness-audit-2026-03-29.md) | `github-project-production-backlog.md` / `PROD-048`, `PROD-050`, `PROD-021` | Rewards durability, referral/account support posture, and operator reconciliation are still open | Active | +| `BSC` | Internal AMM / liquidity dependencies | Partial | [stage-a-promotion-execution-ledger.md](stage-a-promotion-execution-ledger.md), [launch-ops-evidence-index.md](launch-ops-evidence-index.md) | `github-project-production-backlog.md` / `PROD-015A`, `PROD-015B`, `PROD-015C`, `PROD-001`, `PROD-006`, `PROD-007A` | Shared BSC token inputs, AMM settlement-model freeze, canonical AMM fields, and coordinated staged evidence remain open | Active | +| `BSC` | Restart recovery / replay / backfill | Partial | [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md) | `github-project-production-backlog.md` / `PROD-018A`, `PROD-014B` | Recovery is proven in Stage-A, but automated replay, reset, and backfill handling is not finished | Active | +| `BSC` | Observability / alerting / reconciliation | Blocked | [../hyperbet-production-deploy.md](../hyperbet-production-deploy.md), [production-readiness-audit-2026-03-29.md](production-readiness-audit-2026-03-29.md) | `github-project-production-backlog.md` / `PROD-017`, `PROD-018`, `PROD-019` | No production-grade persistence, dashboards, alert thresholds, or operator reconciliation tooling yet | Active | +| `Solana` | Duel discovery / stream ingestion | Partial | [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md), [../runbooks/hyperscapes-local-pm-integration.md](../runbooks/hyperscapes-local-pm-integration.md) | `github-project-production-backlog.md` / `PROD-014`, `PROD-014A`, `PROD-014B`, `PROD-018A` | Solana keeper still needs the same canonical versioned feed, durable checkpoints, and fail-closed parsing story as BSC | Active | +| `Solana` | Market materialization | Green in Stage-A | [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md), [stage-a-promotion-execution-ledger.md](stage-a-promotion-execution-ledger.md) | `github-project-production-backlog.md` / `PROD-001`, `PROD-006` | `goldAmmMarketProgramId` and staged proof packaging still need closeout | Active | +| `Solana` | Trade posting | Green in Stage-A | [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md) | `github-project-production-backlog.md` / `PROD-006`, `PROD-007A` | Needs coordinated staged smoke evidence bundle and production telemetry | Active | +| `Solana` | Result discovery / correlation | Partial | [stage-a-promotion-execution-ledger.md](stage-a-promotion-execution-ledger.md) | `github-project-production-backlog.md` / `PROD-014B`, `PROD-019` | Production result correlation and operator reconciliation remain open | Active | +| `Solana` | Resolve / claim / refund | Green in Stage-A | [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md) | `github-project-production-backlog.md` / `PROD-010`, `PROD-025` | Governance receipts and frozen audit evidence still need closeout | Active | +| `Solana` | Perps / models lifecycle | Green in Stage-A | [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md), [stage-a-promotion-execution-ledger.md](stage-a-promotion-execution-ledger.md) | `github-project-production-backlog.md` / `PROD-005`, `PROD-009`, `PROD-010` | Production custody, staged proof artifacts, and audit evidence are still open | Active | +| `Solana` | Wallet / account shell | Partial | [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md), [../prediction-market-release-prep.md](../prediction-market-release-prep.md) | `github-project-production-backlog.md` / `PROD-047`, `PROD-049` | Shared wallet/account behavior, claims/positions shell truth, and full-app acceptance are not yet frozen as one canonical launch contract | Active | +| `Solana` | Points / referrals / rewards | Partial | [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md), [production-readiness-audit-2026-03-29.md](production-readiness-audit-2026-03-29.md) | `github-project-production-backlog.md` / `PROD-048`, `PROD-050`, `PROD-021` | Rewards durability, referral/account support posture, and operator reconciliation are still open | Active | +| `Solana` | Internal AMM / liquidity dependencies | Green in Stage-A | [stage-a-promotion-execution-ledger.md](stage-a-promotion-execution-ledger.md), [launch-ops-evidence-index.md](launch-ops-evidence-index.md) | `github-project-production-backlog.md` / `PROD-001`, `PROD-006`, `PROD-015B`, `PROD-015C`, `PROD-007A` | AMM settlement-model freeze, Solana settlement account auditability, registry completion, and coordinated staged evidence remain open | Active | +| `Solana` | Restart recovery / replay / backfill | Partial | [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md) | `github-project-production-backlog.md` / `PROD-018A`, `PROD-014B` | Recovery is proven in Stage-A, but automated replay/reset/backfill handling is not finished | Active | +| `Solana` | Observability / alerting / reconciliation | Blocked | [../hyperbet-production-deploy.md](../hyperbet-production-deploy.md), [production-readiness-audit-2026-03-29.md](production-readiness-audit-2026-03-29.md) | `github-project-production-backlog.md` / `PROD-017`, `PROD-018`, `PROD-019` | No production-grade persistence, dashboards, alert thresholds, or operator reconciliation tooling yet | Active | + +## Parked AVAX Note + +`AVAX` is preserved but intentionally parked. Existing AVAX code, evidence, and investigation stay in the repo, but AVAX rows are excluded from the active blocker table until the lane is explicitly reactivated through: + +- `PROD-037 Preserve AVAX as a parked epic without active blocker status` +- `PROD-038 Define AVAX reactivation gates and re-entry checklist` diff --git a/docs/release/stage-a-browser-acceptance-matrix.md b/docs/release/stage-a-browser-acceptance-matrix.md new file mode 100644 index 00000000..6f412763 --- /dev/null +++ b/docs/release/stage-a-browser-acceptance-matrix.md @@ -0,0 +1,192 @@ +# Stage-A Browser Acceptance Matrix + +> **Historical snapshot:** This document preserves browser-to-chain and real-Hyperscapes acceptance evidence for the Stage-A branch effort. Current open-work ownership lives in [tracking-document-map.md](tracking-document-map.md) and [github-project-production-backlog.md](github-project-production-backlog.md). Use this matrix as evidence, not as the canonical blocker list. + +This matrix tracks the browser-to-chain acceptance bar for the current Hyperbet branch against the deployed Stage-A chains: + +- Solana `devnet` +- BSC testnet +- AVAX Fuji + +The browser acceptance work now has two explicit lanes: + +- `synthetic_publish` + - local apps + - local keepers + - real Stage-A chains + - real Stage-A wallets + - duel state injected through `/api/streaming/state/publish` +- `real_hyperscapes` + - local apps + - local keepers + - real Stage-A chains + - real Stage-A wallets + - duel state discovered from the sibling Hyperscapes checkout instead of synthetic publish + +The real Hyperscapes lane remains separate from the synthetic lane so the same browser assertions can be reused while only the duel source changes. + +Scope rule for this matrix: + +- `models` / `agents` is the user-facing perps surface +- AMM is an internal market-making tool and is **not** part of browser-surface acceptance + +## Duel Source Contract + +Accepted non-mainnet duel-source modes are: + +- `synthetic_publish` +- `real_hyperscapes` + +Current status: + +- the public E2E runners now accept the duel-source contract explicitly +- the synthetic browser lane is currently green on: + - BSC: 4 live EVM market-flow cases passing + - AVAX: 4 live EVM market-flow cases passing + - Solana: 5 default live browser cases passing, with the explicit time-gated matured winner-claim lane proven separately on the `real_hyperscapes` path after `finalizableAt` +- the copied Solana placeholder skips were removed from the BSC and AVAX browser suites +- the `real_hyperscapes` lane is now green for the targeted BSC PM, AVAX PM, Solana PM, and Solana CLOB write-path cases using prepared live markets from [/Volumes/OWC Envoy Pro FX/Work/hyperbet/scripts/run-hyperscapes-pm-local.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/scripts/run-hyperscapes-pm-local.sh) +- the `real_hyperscapes` lane is also green for the time-gated Solana matured-claim lane and the bounded observe-only soak +- the updated Hyperscapes `origin/main` commit staged for that swap is `98f8fe26271a63edb61b4b72e4314917a0fa50d7` in `/tmp/hyperscapes-main` +- synthetic on-chain evidence is retained in: + - direct canary artifacts under [/Volumes/OWC Envoy Pro FX/Work/hyperbet/.ci-artifacts/stage-a/direct-canaries](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/.ci-artifacts/stage-a/direct-canaries) + - BSC targeted browser rerun txs: + - recovery YES: `0x79c6a2a7840ad0736b6d85b45fab05296735d67d7655e6ed9b081c040beb35bd` + - cancel YES: `0xc0163a00de2f03133ed1f7b8f58bf5d6611963336bfda418a1d4d95bc390b670` + - cancel refund claim: `0x73277718f9c347752176315d122544007975fbfae0ab64271582383be617bdb6` +- real-duel on-chain evidence now includes the targeted BSC PM rerun: + - YES order: `0x97bd75b787b8d488a7b0ad1d794efd7f6718d63abf0378440e9488a460c2aecd` + - NO order: `0xb98edc456cd953d84266516f642877275444a78c41bf901fdda3447332daafd5` +- real-duel on-chain evidence also includes the targeted AVAX PM rerun: + - YES order: `0x3af62ab49f66739d81746b21ebcb9ceccdc68b859e42766343c21f5b35c93532` + - NO order: `0x261cdba97db07805cd056cf8c5915fe2668f021a5b499da39eb9a771ca6c2417` +- real-duel recovery evidence now includes: + - BSC keeper-restart YES: `0x3abd521c2b89a1f2be898a89e2c67a52f4f5289bf7170aba80f9a30b3bef2a99` + - AVAX keeper-restart YES: `0x079b2c841a3fb02f272e6640d44ba80b6f2ccf0b98305153e9d7a89daf0bed76` + - BSC Hyperscapes-restart YES: `0xa093fc026860c5d5d0f549de4188d32854dd58d6c0773855e4bf964b5f5e3579` + - AVAX Hyperscapes-restart YES: `0x338b3f3cab3f7684bd06eff84a7484a6395e461abe959035d4939421e299e674` + - Solana PM Hyperscapes-restart: browser lane green against the same prepared live duel after service restart + - Solana CLOB Hyperscapes-restart: browser lane green against the rebound live duel after service restart +- real-duel matured-claim evidence now includes: + - finalize signature: `4tkVyYhgZMjTjzmWdoyZPvam7dMKBHj5Zm7CTVuu31yk4nrKYzkKQupPAyNzHx85FXL9dCvEyeKhZUC4WVL5a7Fy` + - claim signature: `2Mopxy5AbnJUHC55VMVeADUcsSVBWLcp9kvBnq41doUGk8GhBmGPZyAcACCfGMC9Qbr9QEcCYPn8mE4CiV2og2bV` + - trader lamport delta: `50609720` +- observe-only soak evidence now includes: + - artifact root: [/Volumes/OWC Envoy Pro FX/Work/hyperbet/output/playwright/pm-soak/2026-03-28T08-03-45-491Z](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/output/playwright/pm-soak/2026-03-28T08-03-45-491Z) + - summary: `pass=true`, `signoffMode=true`, `durationMs=1504787`, `cyclesObserved=7`, `scoredCyclesObserved=6`, `driftCyclesObserved=2`, `incidents=[]` + +## Current Coverage + +### Solana + +Covered: + +- wallet connect and active-chain boot +- duels bottom tabs: `trades`, `orders`, `positions`, `news`, `holders`, `topTraders` +- points drawer open/close +- points leaderboard scope/window toggles +- points history render plus filter toggle +- referral render plus redeem validation +- referral link-wallet CTA state +- surface-mode toggle to `models` and back to `duels` +- PM flows: + - lifecycle shell + - YES and NO order placement + - restart recovery + - cancelled duel refund + - finalize a matured proposal and claim winnings +- real Hyperscapes PM write path: + - prepared live duel selection + - runner and keeper operate from live Hyperscapes state without `/api/streaming/state/publish` + - browser YES and NO orders execute on the prepared live market +- real Hyperscapes CLOB path: + - dedicated CLOB UI consumes the prepared live duel and market from `state.json` + - seeded ask liquidity is funded from the bootstrap authority and confirmed with polling-safe Anchor sends + - browser YES mint is verified on-chain against the prepared live market +- perps flows: + - LONG open/close + - SHORT open/close + +Time-gated winner-claim evidence: + +- finalized winner claim on the recorded real proposal-stage fixture + - current devnet oracle config remains frozen with `disputeWindowSecs=3600`, so this lane is intentionally rerun only after the recorded `finalizableAt` + - fixture: + - duel id `streaming-177dc378-c195-4faf-a3c2-2ef2e945bf33` + - duel key `9ec21f89f3797aac98a35cb401eeeee6e8c269a749505d3def8ec2f7bd6f5be7` + - market `Eg27CiTYX67SdPFrnqeNbyVZePraZ23jxPA9dATaxXeE` + - duel state `BEZYSNQaFDVzThZ8L66YG7SA9vLgtTuVWN8BEi83mqct` + - proposal signature `4iwM3VNyJ8SWjWugyUGkWbE8R5PGrLQrFXp5Autf2pqmDLJggTgdQa3ZDkFSAHEnsvhFabEaLu3gRtp2EKSB1fih` + - `finalizableAt=1774682887` (`2026-03-28 02:28:07 CDT`) + - claim completion: + - finalize signature `4tkVyYhgZMjTjzmWdoyZPvam7dMKBHj5Zm7CTVuu31yk4nrKYzkKQupPAyNzHx85FXL9dCvEyeKhZUC4WVL5a7Fy` + - claim signature `2Mopxy5AbnJUHC55VMVeADUcsSVBWLcp9kvBnq41doUGk8GhBmGPZyAcACCfGMC9Qbr9QEcCYPn8mE4CiV2og2bV` + - trader lamport delta `50609720` + - the browser lane finishes against the same pinned duel and market with terminal claimed state and no remaining payout CTA + - set `E2E_REQUIRE_MATURED_SOLANA_WIN_CLAIM=true` only when intentionally running the timed maturity lane + +### BSC + +Covered: + +- wallet connect and chain selection +- duels bottom tabs: `trades`, `orders`, `positions` +- points drawer open/close +- points leaderboard scope/window toggles +- points history render plus filter toggle +- referral render plus redeem validation +- referral link-wallet CTA state +- locale switch +- surface-mode toggle to `models` and back to `duels` +- models/agents surface as the user-facing perps browser surface +- PM flows: + - fresh live market creation + - YES and NO order placement + - keeper recovery + - cancel and refund +- the BSC PM surface is proven from live targeted reruns on the deployed Stage-A chain, not from mocked receipts +- real Hyperscapes PM write path: + - prepared live market selection + - browser YES order placed on-chain + - browser NO order placed on-chain + - keeper restart recovery + +Not yet fully proven: + +- the models/agents surface is covered as a browser surface, but it is still a lighter acceptance lane than Solana’s writable perps flow +- no browser AMM work is required for signoff + +### AVAX + +Covered: + +- wallet connect and chain selection +- duels bottom tabs: `trades`, `orders`, `positions` +- points drawer open/close +- points leaderboard scope/window toggles +- points history render plus filter toggle +- referral render plus redeem validation +- referral link-wallet CTA state +- locale switch +- theme toggle +- surface-mode toggle to `models` and back to `duels` +- models/agents surface as the user-facing perps browser surface +- PM flows: + - fresh live market creation + - YES and NO order placement + - keeper recovery + - cancel and refund +- real Hyperscapes PM write path: + - prepared live market selection + - browser YES order placed on-chain + - browser NO order placed on-chain + - keeper restart recovery + +Not yet fully proven: + +- the models/agents surface is the correct AVAX browser surface for acceptance +- no browser AMM work is required for signoff + +## Honest Remaining Work + +None. The bounded observe-only real-duel soak and the time-gated Solana matured-winner-claim lane are both complete on this branch. diff --git a/docs/release/stage-a-promotion-execution-ledger.md b/docs/release/stage-a-promotion-execution-ledger.md new file mode 100644 index 00000000..408649ec --- /dev/null +++ b/docs/release/stage-a-promotion-execution-ledger.md @@ -0,0 +1,1743 @@ +# Stage-A Promotion Execution Ledger + +> **Historical snapshot:** This is an append-only execution ledger for the Stage-A promotion run. Current open-work ownership lives in [tracking-document-map.md](tracking-document-map.md) and [github-project-production-backlog.md](github-project-production-backlog.md). Use this file for evidence and transaction history, not as the canonical blocker list. + +> **TL;DR:** This is the live execution log for the current non-mainnet promotion run. The Stage-A wallet set under `keys/stage-a/` is live, all three Stage-A deployments are on-chain on `Solana devnet`, `BSC testnet`, and `AVAX Fuji`, and direct protocol canaries are green on all three chains. Synthetic browser-to-chain acceptance is green on BSC and AVAX and green on the default Solana browser lanes. The explicit Solana matured-winner-claim lane is proven separately in-browser on the recorded `real_hyperscapes` fixture after `finalizableAt`. The real Hyperscapes browser lane is green for the targeted BSC PM, AVAX PM, Solana PM, Solana CLOB, keeper-restart recovery, Hyperscapes-restart recovery, and the bounded observe-only soak. The recorded real Solana proposal-stage fixture is now also finalized and claimed on-chain in-browser. Stage-A browser-to-chain signoff on this branch is complete. AMM is not part of browser-surface signoff. + +This document records the exact commands, balances, transaction hashes, and blockers for the current Stage-A promotion run. It is intentionally operational and append-only for this execution cycle. + +## Scope + +- Launch rehearsal targets: + - `Solana devnet` + - `BSC testnet` + - `AVAX Fuji` +- Wallet source of truth: + - `keys/stage-a/` +- Current execution branch: + - `audit/develop-pm-hardening` + +## Operating Rules + +1. Do not record private keys, seed phrases, or raw secret values in this file. +2. Record commands, public addresses, tx hashes, balances, outputs, and blockers. +3. Record every meaningful state transition before moving to the next step. +4. Prefer local-first deployment and verification. Use GitHub Actions when existing CI-held wallets are needed for bootstrap funding. + +## Stage-A Wallet Inventory + +Public wallet inventory is generated from: + +- [public-addresses.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/keys/stage-a/public-addresses.json) + +Local shell export helpers exist at: + +- [export-stage-a.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/keys/stage-a/export-stage-a.sh) +- [stage-a.env](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/keys/stage-a/stage-a.env) + +## Progress Tracker + +| Step | Status | Notes | +| --- | --- | --- | +| Generate new Stage-A wallets | Complete | Wallets created locally under `keys/stage-a/` | +| Fund Solana devnet deployer | Complete | New Solana deployer funded | +| Fund AVAX Fuji deployer | Complete | New AVAX deployer funded | +| Fund BSC testnet deployer | Complete | Funded via GitHub Actions from old deployer | +| Deploy fresh BSC non-mainnet collateral tokens | Complete | Addresses written to `keys/stage-a/token-addresses*.json` | +| Deploy fresh AVAX non-mainnet collateral tokens | Complete | Final corrected deploy recorded after env-precedence fix | +| Deploy BSC PM + AMM + perps | Complete | Full BSC Stage-A product is deployed and verified | +| Deploy AVAX PM + AMM + perps | Complete | Full AVAX Stage-A product is deployed and verified | +| Verify BSC and AVAX deployment receipts | Complete | Canonical receipts are populated for the deployed Stage-A product | +| Resolve Solana devnet keypair/program-id alignment | Complete | Strict new-wallet-only Solana Stage-A IDs are live on devnet and verified | +| Run staged proof and staged soak | Complete | Direct canaries are green; synthetic browser lane is green; BSC, AVAX, Solana PM, Solana CLOB, keeper-restart recovery, Hyperscapes-restart recovery, bounded observe-only soak, and matured Solana claim are green | + +## Execution Log + +### Step 0. Create the new Stage-A wallet set + +Status: +- Complete + +Artifacts: +- [public-addresses.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/keys/stage-a/public-addresses.json) +- [export-stage-a.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/keys/stage-a/export-stage-a.sh) +- [stage-a.env](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/keys/stage-a/stage-a.env) + +Public addresses of primary deployers: +- BSC / AVAX EVM deployer: + - `0x4f2714dc431dc948B5B138Ef9b998e943568DE4d` +- Solana devnet deployer: + - `B6rVRCTCUxWQ5fmT1fboPsnbuMuwoKpWSCBK3NHbs83w` + +Notes: +- Wallet files are local and gitignored. +- Existing export files already contain the Stage-A wallet envs. Loading them into a shell is still required before local deploy scripts can use them. + +### Step 1. Fund the new deployer wallets + +Status: +- Complete + +#### 1A. Solana devnet deployer + +Status: +- Complete + +Confirmed balance: +- `B6rVRCTCUxWQ5fmT1fboPsnbuMuwoKpWSCBK3NHbs83w` + - `5 SOL` + +#### 1B. AVAX Fuji deployer + +Status: +- Complete + +Confirmed balance: +- `0x4f2714dc431dc948B5B138Ef9b998e943568DE4d` + - `3 AVAX` + +#### 1C. BSC testnet deployer + +Status: +- Complete + +Funding source: +- Old CI-held deployer: + - `0x25DFe05ea0d5bb2F96b9D351765CC5E2DB86dCC0` + +Funding workflow: +- [fund-stage-a-wallets.yml](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/.github/workflows/fund-stage-a-wallets.yml) + +GitHub Actions run: +- `23590515407` + +Funding transaction: +- `0xc3b00e93519b4b7dbe8264590dcb9b4859b2f72dcff8ad4f89d75d14c2c4f2bc` + +Confirmed balances after funding: +- Old BSC deployer: + - `0x25DFe05ea0d5bb2F96b9D351765CC5E2DB86dCC0` + - `0.17939378 tBNB` +- New BSC deployer: + - `0x4f2714dc431dc948B5B138Ef9b998e943568DE4d` + - `0.2 tBNB` + +Notes: +- The workflow could not be `workflow_dispatch`ed directly because GitHub does not expose non-default-branch-only workflows for dispatch. +- The workflow was updated to also run on pushes to `audit/develop-pm-hardening`, then triggered via a commit/push to the same branch. + +### Step 2. Prepare fresh non-mainnet ERC20 collateral tokens + +Status: +- Complete + +Purpose: +- BSC and AVAX phase-1 AMM and perps need fresh local non-mainnet token contracts because there are no reusable existing `mUSD` or perps margin token contracts currently provisioned for this Stage-A wallet set. + +Script: +- [deploy-stage-a-tokens.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/evm-contracts/scripts/deploy-stage-a-tokens.ts) + +What this script deploys: +- `MockUSD` + - used as AMM collateral on EVM non-mainnet +- `MockERC20` + - used as the perps margin token on EVM non-mainnet + +What this script writes: +- [token-addresses.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/keys/stage-a/token-addresses.json) +- `token-addresses.bscTestnet.json` +- `token-addresses.avaxFuji.json` + +Expected outputs: +- `BSC_TESTNET_MUSD_TOKEN_ADDRESS` +- `BSC_TESTNET_PERPS_MARGIN_TOKEN_ADDRESS` +- `BSC_TESTNET_GOLD_TOKEN_ADDRESS` +- `AVAX_FUJI_MUSD_TOKEN_ADDRESS` +- `AVAX_FUJI_PERPS_MARGIN_TOKEN_ADDRESS` +- `AVAX_FUJI_GOLD_TOKEN_ADDRESS` + +Important note: +- On EVM, `*_GOLD_TOKEN_ADDRESS` is currently a compatibility alias to the perps margin token for repo/workflow compatibility. It is not modeling a distinct real Gold token on BSC or AVAX. + +#### 2A. BSC testnet token deployment + +Status: +- Complete + +Command: + +```bash +source /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/keys/stage-a/export-stage-a.sh +bun run --cwd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/evm-contracts deploy:stage-a-tokens:bsc-testnet +``` + +Execution timestamp: +- `2026-03-26T11:02:50.941Z` + +Output files: +- [token-addresses.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/keys/stage-a/token-addresses.json) +- [token-addresses.bscTestnet.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/keys/stage-a/token-addresses.bscTestnet.json) + +Deployed BSC token addresses: +- `BSC_TESTNET_MUSD_TOKEN_ADDRESS` + - `0x08e621f503aCe8cCCE745fe7441561536AE8445F` +- `BSC_TESTNET_PERPS_MARGIN_TOKEN_ADDRESS` + - `0xB5e215607565808d00b16c69a8074d35060438DE` +- `BSC_TESTNET_GOLD_TOKEN_ADDRESS` + - `0xB5e215607565808d00b16c69a8074d35060438DE` + - compatibility alias to the perps margin token + +Mint recipients: +- deployer +- admin +- market operator +- market maker +- keeper +- canary +- matcher + +Mint amount per recipient: +- `100000` `mUSD` +- `100000` perps margin tokens + +Notes: +- The script completed successfully on `bscTestnet` using the new Stage-A deployer. +- The deployment uses fresh non-mainnet ERC20s only for Stage-A AMM and perps rehearsal. + +#### 2B. AVAX Fuji token deployment + +Status: +- Complete + +First attempt: +- A first AVAX deploy succeeded on-chain, but it used the wrong deployer account. +- Cause: + - Hardhat prefers `AVAX_FUJI_PRIVATE_KEY` over `PRIVATE_KEY`. + - The Stage-A export initially set `PRIVATE_KEY` only, while a stale `AVAX_FUJI_PRIVATE_KEY` was still present in the local environment. +- First-attempt outcome: + - contracts were deployed from `0x7b3D508340f3465A0D57dD54df163A5Fb889bD26` + - minted balances still went to the new Stage-A recipient set +- Resolution: + - patched [export-stage-a-wallet-env.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/scripts/export-stage-a-wallet-env.ts) to emit: + - `BSC_TESTNET_PRIVATE_KEY` + - `AVAX_FUJI_PRIVATE_KEY` + - regenerated: + - [export-stage-a.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/keys/stage-a/export-stage-a.sh) + - [stage-a.env](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/keys/stage-a/stage-a.env) + - re-ran the AVAX token deployment under the corrected Stage-A env + +Corrected command: + +```bash +source /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/keys/stage-a/export-stage-a.sh +bun run --cwd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/evm-contracts deploy:stage-a-tokens:avax-fuji +``` + +Corrected execution timestamp: +- `2026-03-26T11:07:46.620Z` + +Output files: +- [token-addresses.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/keys/stage-a/token-addresses.json) +- [token-addresses.avaxFuji.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/keys/stage-a/token-addresses.avaxFuji.json) + +Corrected deployer: +- `0x4f2714dc431dc948B5B138Ef9b998e943568DE4d` + +Deployed AVAX token addresses: +- `AVAX_FUJI_MUSD_TOKEN_ADDRESS` + - `0x08e621f503aCe8cCCE745fe7441561536AE8445F` +- `AVAX_FUJI_PERPS_MARGIN_TOKEN_ADDRESS` + - `0xB5e215607565808d00b16c69a8074d35060438DE` +- `AVAX_FUJI_GOLD_TOKEN_ADDRESS` + - `0xB5e215607565808d00b16c69a8074d35060438DE` + - compatibility alias to the perps margin token + +Important note: +- The final AVAX token addresses match the BSC token addresses. +- That is expected here because the same Stage-A deployer executed the same deployment sequence on a different chain, resulting in the same address derivation pattern. + +### Step 3. Deploy BSC PM + AMM + perps + +Status: +- In progress + +#### 3A. BSC PM-core CREATE2 deployment + +Status: +- Complete + +Command: + +```bash +source /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/keys/stage-a/export-stage-a.sh +bun run --cwd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/evm-contracts deploy:create2:bsc-testnet +``` + +Execution timestamp: +- `2026-03-26T11:10:00.113Z` + +Canonical receipt: +- [bscTestnet.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/evm-contracts/deployments/bscTestnet.json) + +Deployer: +- `0x4f2714dc431dc948B5B138Ef9b998e943568DE4d` + +Constructor roles used: +- admin: + - `0xd75676D9466db83a74E55572e45828751e2e8101` +- reporter: + - `0x8FA91419FDC9d25A646c6B9684C148DeFa1C2df0` +- finalizer: + - `0x67e3078B238F9eAA04C60834ccCc6A4685F704b7` +- challenger: + - `0x3A123B14bca41E14d12d4854F6a1180ECC38b42b` +- pauser: + - `0xdeB6a5897C5B2FbB3C7dCe821b5ffe69aeA60d51` +- market operator: + - `0x7E82E3553f1baB7964c6dbd760d565be14D566bb` +- treasury: + - `0xae6bd4dAC4731995980EEdFF70ccC6B29E8FbD62` +- market maker: + - `0xc2Aac21a8bb955c3D6a76b77A9824C8cd81d6f5B` +- dispute window: + - `3600` + +Deployed addresses: +- `duelOracleAddress` + - `0x5B0a0D5cf66F2A725560fCdb3bF74067c8c50A3C` +- `goldClobAddress` + - `0xb7b2833875A17d5E5401C310C694Bb75a21a2582` + +Deployment transactions: +- oracle: + - `0xb93a97b918c2a7764f1d6c406849e3b8dddee223d82be1f22d476cb8acda53a4` +- clob: + - `0x23096538668f896d7111509b0790c849399c0a7a3a5d37f15c6cace3e9fb2e11` + +Notes: +- The new Stage-A deployer successfully used the Arachnid deterministic deployment proxy. +- These PM-core addresses are now the oracle and CLOB inputs for the next BSC AMM and verification steps. + +#### 3B. BSC AMM deployment + +Status: +- Blocked + +Attempted command: + +```bash +source /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/keys/stage-a/export-stage-a.sh +export DUEL_ORACLE_ADDRESS=0x5B0a0D5cf66F2A725560fCdb3bF74067c8c50A3C +export MUSD_TOKEN_ADDRESS=0x08e621f503aCe8cCCE745fe7441561536AE8445F +bun run --cwd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/evm-contracts deploy:amm:bsc-testnet +``` + +What happened: +- First failure: + - `Router` deployment script did not link the required `Math` and `SwapMath` libraries. +- Fix applied: + - patched [deploy-amm.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/evm-contracts/scripts/deploy-amm.ts) to deploy and link: + - `contracts/lvr_amm/lib/Math.sol:Math` + - `contracts/lvr_amm/lib/SwapMath.sol:SwapMath` +- Second failure: + - short library name `Math` was ambiguous with OpenZeppelin `Math` +- Fix applied: + - updated the same script to use fully qualified library names +- Third failure: + - on-chain deployment reverted with: + - `ProviderError: max code size exceeded` + +Measured compiled sizes: +- `Router` + - runtime size: + - `26124` bytes + - creation size: + - `27147` bytes +- EVM runtime limit: + - `24576` bytes + +Current conclusion: +- The current `Router` implementation is too large to deploy on BSC testnet as compiled. +- This is an implementation blocker for the EVM AMM lane, not a wallet or environment problem. + +#### 3C. BSC perps deployment + +Status: +- Complete + +Command: + +```bash +source /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/keys/stage-a/export-stage-a.sh +export PERPS_MARGIN_TOKEN_ADDRESS=0xB5e215607565808d00b16c69a8074d35060438DE +export GOLD_TOKEN_ADDRESS=0xB5e215607565808d00b16c69a8074d35060438DE +bun run --cwd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/evm-contracts deploy:perps:bsc-testnet +``` + +Execution timestamp: +- `2026-03-26T11:12:53.497Z` + +Canonical receipt: +- [bscTestnet.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/evm-contracts/deployments/bscTestnet.json) + +Deployed addresses: +- `skillOracleAddress` + - `0x9064B86E9050bb9C01868B36740D6d9F12a18F0d` +- `perpEngineAddress` + - `0x0c6c9B3A7C374F121a0Ad0bBFB01945d1F567cfc` +- `perpMarginTokenAddress` + - `0xB5e215607565808d00b16c69a8074d35060438DE` + +Deployment transactions: +- `SkillOracle` + - `0x36f78eeeb87013affda44fa1a3c3dc89c977a3acc003ed98a791ac7c9ff34d36` +- `AgentPerpEngine` + - `0xcef23a7bf0de93c7217bb12b9ba12f6b9ca5920b67503e0f36bc4c27327a270a` + +Notes: +- BSC perps deployment completed successfully under the new Stage-A deployer. +- The canonical BSC receipt is now populated for PM-core plus perps, but not AMM. + +### Step 4. Deploy AVAX PM + AMM + perps + +Status: +- In progress + +#### 4A. AVAX PM-core CREATE2 deployment + +Status: +- Complete + +Command: + +```bash +source /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/keys/stage-a/export-stage-a.sh +bun run --cwd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/evm-contracts deploy:create2:avax-fuji +``` + +Execution timestamp: +- `2026-03-26T11:13:51.528Z` + +Canonical receipt: +- [avaxFuji.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/evm-contracts/deployments/avaxFuji.json) + +Deployer: +- `0x4f2714dc431dc948B5B138Ef9b998e943568DE4d` + +Deployed addresses: +- `duelOracleAddress` + - `0x5B0a0D5cf66F2A725560fCdb3bF74067c8c50A3C` +- `goldClobAddress` + - `0xb7b2833875A17d5E5401C310C694Bb75a21a2582` + +Deployment transactions: +- oracle: + - `0x9ddab4aca88ff472c5b654742447a3816b294efc30a0536375d0d4f4e258c801` +- clob: + - `0x2f967a345d20702681a1edf6ac233712448883be5ac407a38c48551aa9be8de4` + +Notes: +- The AVAX PM-core redeploy replaced the old stale-wallet receipt with a clean Stage-A deployer receipt. +- As expected, the AVAX PM-core addresses match the BSC PM-core addresses because the same Stage-A deployer executed the same CREATE2 deployment with the same constructor arguments on another EVM chain. + +#### 4B. AVAX AMM deployment + +Status: +- Blocked by the same implementation issue as BSC + +Current conclusion: +- The AMM `Router` runtime size is chain-agnostic for these EVM deployments. +- Since the compiled runtime size is `26124` bytes, it exceeds the EVM runtime code-size limit on AVAX just as it does on BSC. +- The AVAX AMM lane is therefore blocked by the same `Router` implementation issue and was not re-attempted separately after the BSC proof. + +#### 4C. AVAX perps deployment + +Status: +- Complete + +First attempt: +- The first AVAX perps run was terminated by the desktop execution layer with `SIGKILL`. +- Chain check after the interruption showed no code at the candidate perps addresses, so that first attempt did not leave partial deployed state. + +Successful retry command: + +```bash +source /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/keys/stage-a/export-stage-a.sh +export PERPS_MARGIN_TOKEN_ADDRESS=0xB5e215607565808d00b16c69a8074d35060438DE +export GOLD_TOKEN_ADDRESS=0xB5e215607565808d00b16c69a8074d35060438DE +bun run --cwd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/evm-contracts deploy:perps:avax-fuji +``` + +Execution timestamp: +- `2026-03-26T11:15:00.735Z` + +Canonical receipt: +- [avaxFuji.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/evm-contracts/deployments/avaxFuji.json) + +Deployed addresses: +- `skillOracleAddress` + - `0x249db7Bf653b4Dfe20201B1d578F1df77b9f2a94` +- `perpEngineAddress` + - `0x9064B86E9050bb9C01868B36740D6d9F12a18F0d` +- `perpMarginTokenAddress` + - `0xB5e215607565808d00b16c69a8074d35060438DE` + +Deployment transactions: +- `SkillOracle` + - `0x093bf63a7f75d9aaad3d293991a85b2e951c333aa1f6feebe823f4b49dc82246` +- `AgentPerpEngine` + - `0xa7abd767a84698b8c258bc1225b11cf4d94e6aae08b2e73eaca2bbfc13103553` + +Notes: +- The AVAX perps retry completed cleanly under the new Stage-A deployer. +- The canonical AVAX receipt is now populated for PM-core plus perps, but not AMM. + +### Step 5. Verify current BSC and AVAX deployment receipts + +Status: +- Blocked + +Purpose: +- Confirm the canonical non-mainnet receipts are complete enough for full-product verification. + +#### 5A. BSC receipt verification + +Status: +- Blocked + +Command: + +```bash +source /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/keys/stage-a/export-stage-a.sh +node --import tsx /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/evm-contracts/scripts/verify-deployment.ts --network bscTestnet +``` + +Observed result: +- Verification exited early with a missing-address error. + +Missing receipt fields reported by the verifier: +- `goldAmmRouterAddress` + - empty +- `mUsdTokenAddress` + - empty + +Conclusion: +- The BSC canonical receipt correctly reflects that PM-core and perps exist, but AMM is not deployed and the canonical receipt does not yet carry AMM-linked fields. +- This failure is expected until the EVM AMM lane is fixed and deployed. + +#### 5B. AVAX receipt verification + +Status: +- Blocked + +Command: + +```bash +source /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/keys/stage-a/export-stage-a.sh +node --import tsx /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/evm-contracts/scripts/verify-deployment.ts --network avaxFuji +``` + +Observed result: +- Verification exited early with a missing-address error. + +Missing receipt fields reported by the verifier: +- `goldAmmRouterAddress` + - empty +- `mUsdTokenAddress` + - empty + +Conclusion: +- The AVAX canonical receipt correctly reflects that PM-core and perps exist, but AMM is not deployed and the canonical receipt does not yet carry AMM-linked fields. +- This failure is expected until the EVM AMM lane is fixed and deployed. + +### Step 6. Solana devnet preflight + +Status: +- Blocked + +Command: + +```bash +source /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/keys/stage-a/export-stage-a.sh +bun run --cwd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/hyperbet-solana deploy:preflight:devnet +``` + +Observed result: +- Preflight completed its checks and reported four failures. +- IDL manifest checks passed. +- Program keypair/pubkey alignment checks failed for all four Solana programs. + +Failed program keypair checks: +- `fight_oracle` + - expected manifest program id: + - `B5mRCRDJk9BrnH7regMWW5mpTQ8QG1CcCGSnDxMt8hmo` +- `gold_clob_market` + - expected manifest program id: + - `DYtd7AoyTX2tbmZ8vpC3mxZgqTpyaDei4TFXZukWBJEf` +- `lvr_amm` + - expected manifest program id: + - `Af4LMYfaBtcFFM6dBjwLYH6QJLMqEwneQ8VHfn2z7NY5` +- `gold_perps_market` + - expected manifest program id: + - `EoZdHN8U3qWQje48ToxB1SLWjucsFGqcWaRUJQYX3eoT` + +Conclusion: +- The current local Solana deploy keypairs under `packages/hyperbet-solana/anchor/target/deploy/` do not match the manifest / registry program IDs. +- Solana devnet deploy cannot proceed honestly until either: + - the correct program keypairs are provided, or + - the manifest / registry truth is intentionally rotated to new program IDs. + +### Step 7. Remaining execution path + +Pending steps after token deployment: + +1. Fund any Stage-A non-deployer roles that need native gas for operational actions. +2. Decide whether to shrink/refactor the EVM AMM `Router` or otherwise change the EVM AMM deployment approach so it fits under the EVM code-size limit. +3. Re-run BSC and AVAX verification after the AMM lane is fixed and the canonical receipts include AMM fields. +4. Resolve Solana devnet program-keypair alignment and run Solana devnet deploy/init/verify. +5. Run staged proof and staged soak only after the missing EVM AMM lane and Solana deploy lane are resolved. + +## Current Blockers + +Known remaining blockers before full non-mainnet signoff: + +1. Solana devnet still has a program keypair vs registry mismatch that must be resolved before deployment can proceed. +2. EVM AMM `Router` exceeds the EVM runtime code-size limit as currently compiled, blocking BSC and likely AVAX AMM deployment. +3. BSC and AVAX verification cannot pass yet because the AMM receipt fields are still absent from the canonical receipts. +4. GitHub `staging` environment and staged vars/secrets are still not provisioned, which blocks staged proof and staged soak in GitHub. + +## Update: EVM AMM Unblock And Solana Mixed-Mode Preflight + +Execution window: +- `2026-03-26T11:36Z` through `2026-03-26T11:47Z` + +### Step 8. Shrink the EVM AMM Router and validate locally + +Status: +- Complete + +Files changed: +- [Router.sol](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/evm-contracts/contracts/lvr_amm/Router.sol) +- [LvrMarket.t.sol](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/evm-contracts/test/LvrMarket.t.sol) +- [goldAmmRouterAbi.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-ui/src/lib/goldAmmRouterAbi.ts) +- [deploy-amm.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/evm-contracts/scripts/deploy-amm.ts) + +Implementation result: +- Removed OpenZeppelin `AccessControl` from `Router`. +- Replaced it with a minimal fixed-role check: + - `DEFAULT_ADMIN_ROLE` + - `MARKET_OPERATOR_ROLE` + - `hasRole(bytes32,address)` +- Removed unused AMM metadata/index helpers from `Router`: + - `allMarketIds` + - `getMarketCount` + - `getMarketAtIndex` + - `getMarketMetadata` + - stored metadata / liquidity mirror +- Kept the active AMM runtime surface: + - `create` + - `getAllMarkets` + - `buyYes` + - `buyNo` + - `sellYes` + - `sellNo` + - `proposerOutcome` + - `redeem` + - `settleFromOracle` + - `settleMarket` + - `freezeConfig` + - `configFrozen` + - `hasRole` +- Updated the Solidity tests to use `getAllMarkets()` instead of removed count/index helpers. +- Replaced the stale static UI ABI with the reduced Router surface. +- Fixed `deploy-amm.ts` so `freezeConfig()` is sent by the configured admin signer instead of always by the deployer signer. + +Measured runtime size: +- Router deployed runtime: `22877` bytes +- EIP-170 limit: `24576` bytes +- Headroom recovered: `1699` bytes + +Validation: +- `bun x hardhat compile` + - passed +- `forge test --match-contract LvrMarketTest` + - passed + - `32 passed / 0 failed` +- `git diff --check` + - clean + +Conclusion: +- The EVM AMM size blocker is resolved. + +### Step 9. Fund Stage-A EVM admin for AMM freeze + +Status: +- Complete + +Reason: +- After the Router fix, BSC AMM deployment reached `freezeConfig()` and failed correctly because the separate admin wallet had no native gas. + +Funding transactions: +- BSC admin funding tx + - `0x6c01b44df850a26ef50a8ff5c681ce2139e7687e3055beeb5cba0d96b6f48128` + - resulting admin balance: + - `0.01 tBNB` +- AVAX admin funding tx + - `0x64160c9d036f4ee7c5f10f6360e91c8a1d7814659763e244c46a5c80e3f3cb88` + - resulting admin balance: + - `0.1 AVAX` + +Conclusion: +- The AMM deploy path now has both the deployer and the separate admin role funded enough for deploy plus freeze. + +### Step 10. Redeploy and verify BSC full product + +Status: +- Complete + +AMM deployment command: + +```bash +source /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/keys/stage-a/export-stage-a.sh +export MUSD_TOKEN_ADDRESS=0x08e621f503aCe8cCCE745fe7441561536AE8445F +export DUEL_ORACLE_ADDRESS=0x5B0a0D5cf66F2A725560fCdb3bF74067c8c50A3C +bun run --cwd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/evm-contracts deploy:amm:bsc-testnet +``` + +AMM deployment result: +- `goldAmmRouterAddress` + - `0xc053e98CC2Ef1CF1328E6c9d467B433E1abcEf6d` +- `ammMathLibraryAddress` + - `0xBfF1D19924e1Fe618151363E0467CEB97E78C915` +- `ammSwapMathLibraryAddress` + - `0x8D695Aa351868543bD7eb9a9cF22C5036472cC8B` +- `ammConfigFrozen` + - `true` + +Canonical receipt: +- [bscTestnet.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/evm-contracts/deployments/bscTestnet.json) + +Verification command: + +```bash +source /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/keys/stage-a/export-stage-a.sh +node --import tsx /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/evm-contracts/scripts/verify-deployment.ts --network bscTestnet --out /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/.ci-artifacts/stage-a/bscTestnet.json +``` + +Verification artifact: +- [.ci-artifacts/stage-a/bscTestnet.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/.ci-artifacts/stage-a/bscTestnet.json) + +Verification result: +- Passed with `failures: []` + +Conclusion: +- BSC PM + CLOB + AMM + perps is green under the Stage-A wallet set. + +### Step 11. Redeploy and verify AVAX full product + +Status: +- Complete + +AMM deployment command: + +```bash +source /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/keys/stage-a/export-stage-a.sh +export MUSD_TOKEN_ADDRESS=0x08e621f503aCe8cCCE745fe7441561536AE8445F +export DUEL_ORACLE_ADDRESS=0x5B0a0D5cf66F2A725560fCdb3bF74067c8c50A3C +bun run --cwd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/evm-contracts deploy:amm:avax-fuji +``` + +AMM deployment result: +- `goldAmmRouterAddress` + - `0x537b46C2Cd80f18E47f700825d1Bf886701AA8Dd` +- `ammMathLibraryAddress` + - `0x31Ee5701EF01Bc4FAdCBB935A0a4D7C60f5812a0` +- `ammSwapMathLibraryAddress` + - `0x06b288A6e0684Aaa686Cba984E19Ca35bEb5C748` +- `ammConfigFrozen` + - `true` + +Canonical receipt: +- [avaxFuji.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/evm-contracts/deployments/avaxFuji.json) + +Verification command: + +```bash +source /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/keys/stage-a/export-stage-a.sh +export AVAX_FUJI_RPC=https://avax-fuji.g.alchemy.com/v2/h85R-i8JMJTM3RRVgxLza +node --import tsx /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/evm-contracts/scripts/verify-deployment.ts --network avaxFuji --out /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/.ci-artifacts/stage-a/avaxFuji.json +``` + +Verification artifact: +- [.ci-artifacts/stage-a/avaxFuji.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/.ci-artifacts/stage-a/avaxFuji.json) + +Verification result: +- Passed with `failures: []` + +Operational note: +- The default AVAX verification RPC path timed out from the desktop shell. +- Re-running with the explicit Fuji Alchemy URL from `packages/evm-contracts/.env` succeeded immediately. + +Conclusion: +- AVAX PM + CLOB + AMM + perps is green under the Stage-A wallet set. + +### Step 12. Rework Solana preflight and deploy semantics + +Status: +- Complete at the script level + +Files changed: +- [preflight-contract-deploy.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/scripts/preflight-contract-deploy.ts) +- [verify-deployment.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/scripts/verify-deployment.ts) +- [deploy-programs.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/anchor/scripts/deploy-programs.sh) +- [export-stage-a-env.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/scripts/export-stage-a-env.sh) + +Implementation result: +- Preflight now distinguishes: + - `upgrade` mode when the canonical program already exists on chain + - `fresh-deploy` mode when the canonical program is missing +- Upgrade-mode checks now validate: + - on-chain executable program exists + - upgrade authority matches the expected old deployer + - anchor/app/keeper IDLs still match the manifest +- Fresh-deploy mode still requires local keypair pubkey to match the manifest target id. +- `deploy-programs.sh` now: + - upgrades existing programs by canonical program id + - fresh-deploys missing programs only when local keypair and manifest agree +- Solana verification now defaults expected authority and upgrade authority from the active wallet path if explicit overrides are absent. +- `export-stage-a-env.sh` no longer force-overwrites explicit Solana authority overrides. + +Validation: +- `bash -n packages/hyperbet-solana/anchor/scripts/deploy-programs.sh` + - passed +- `bash -n scripts/export-stage-a-env.sh` + - passed + +Behavioral proof: + +PM-only preflight command: + +```bash +SOLANA_EXPECTED_AUTHORITY=4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya \ +SOLANA_EXPECTED_UPGRADE_AUTHORITY=4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya \ +bun run --cwd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/hyperbet-solana deploy:preflight:devnet:pm +``` + +PM-only preflight result: +- passed +- `fight_oracle` + - upgrade mode + - authority matched `4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya` +- `gold_clob_market` + - upgrade mode + - authority matched `4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya` + +Full-product preflight command: + +```bash +SOLANA_EXPECTED_AUTHORITY=4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya \ +SOLANA_EXPECTED_UPGRADE_AUTHORITY=4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya \ +bun run --cwd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/hyperbet-solana deploy:preflight:devnet +``` + +Full-product preflight result: +- failed only on the missing canonical full-product programs: + - `lvr_amm` + - fresh-deploy mode + - canonical keypair file still missing / mismatched + - `gold_perps_market` + - fresh-deploy mode + - canonical keypair file still missing / mismatched + +Conclusion: +- Solana PM is no longer blocked by stale local keypair files. +- Solana full-product is now blocked only by the unrecovered canonical keypairs for `lvr_amm` and `gold_perps_market`, or by the need to intentionally rotate those two IDs. + +## Current Blockers (Updated) + +Known remaining blockers before full non-mainnet signoff: + +1. Solana devnet still needs either: + - recovery of the canonical `lvr_amm` and `gold_perps_market` keypair files, or + - an explicit rotation of only those two program IDs. +2. Solana devnet deploy/init/verify has not yet been re-run end-to-end under the new mixed upgrade/fresh-deploy semantics. +3. GitHub `staging` environment and staged vars/secrets are still not provisioned, which blocks staged proof and staged soak in GitHub. + +## Current State Summary + +What is now green: + +1. EVM AMM Router size is under the EIP-170 limit. +2. BSC full product verifies cleanly. +3. AVAX full product verifies cleanly. +4. Solana PM-only devnet preflight passes in upgrade mode under the old authority wallet. + +What is still pending: + +1. Solana full-product devnet deploy decision: + - recover canonical AMM/perps keypairs, or + - rotate those two program IDs +2. Solana init/freeze/verify after that decision +3. staging proof and soak after Solana is green + +### Step 13. Rotate Solana AMM/perps IDs and validate fresh-deploy readiness + +Status: +- Complete at the repo and preflight level + +Reason for rotation: +- The canonical devnet PM programs already existed on-chain and remained under the old upgrade authority. +- The canonical devnet `lvr_amm` and `gold_perps_market` IDs did not exist on-chain. +- The only local AMM/perps program keypairs available in `anchor/target/deploy` resolved to: + - `lvr_amm`: `BGmzj676aVzRaJ3Hb9BJRYrjtXuhzoc1YTFA6wcucUNF` + - `gold_perps_market`: `6YjWiway8kaSjwtAinJxqWPvV3DqBVapDWAsSEZjjmbP` +- No local keypair file matching the old placeholder IDs was recoverable from the repo or local Solana config directory. + +Files changed: +- [packages/hyperbet-solana/anchor/programs/lvr_amm/src/lib.rs](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/anchor/programs/lvr_amm/src/lib.rs) +- [packages/hyperbet-solana/anchor/programs/gold_perps_market/src/lib.rs](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/anchor/programs/gold_perps_market/src/lib.rs) +- [packages/hyperbet-solana/anchor/Anchor.toml](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/anchor/Anchor.toml) +- [packages/hyperbet-chain-registry/src/index.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-chain-registry/src/index.ts) +- [packages/hyperbet-chain-registry/tests/chainRegistry.test.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-chain-registry/tests/chainRegistry.test.ts) +- [packages/hyperbet-solana/deployments/contracts.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/deployments/contracts.json) +- [packages/hyperbet-deployments/contracts.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-deployments/contracts.json) +- [packages/hyperbet-solana/app/src/lib/programIds.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/app/src/lib/programIds.ts) +- [packages/hyperbet-solana/anchor/scripts/run-localnet-tests.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/anchor/scripts/run-localnet-tests.sh) +- [packages/hyperbet-solana/app/scripts/run-e2e-local.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/app/scripts/run-e2e-local.sh) +- [packages/hyperbet-solana/app/scripts/run-local-demo.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/app/scripts/run-local-demo.sh) +- [packages/hyperbet-bsc/app/scripts/run-e2e-local.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-bsc/app/scripts/run-e2e-local.sh) +- [packages/hyperbet-bsc/app/scripts/run-local-demo.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-bsc/app/scripts/run-local-demo.sh) +- [packages/hyperbet-avax/app/scripts/run-e2e-local.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-avax/app/scripts/run-e2e-local.sh) + +Rotation result: +- `goldAmmMarketProgramId` + - old placeholder: `Af4LMYfaBtcFFM6dBjwLYH6QJLMqEwneQ8VHfn2z7NY5` + - rotated repo truth: `BGmzj676aVzRaJ3Hb9BJRYrjtXuhzoc1YTFA6wcucUNF` +- `goldPerpsMarketProgramId` + - old placeholder: `EoZdHN8U3qWQje48ToxB1SLWjucsFGqcWaRUJQYX3eoT` + - rotated repo truth: `6YjWiway8kaSjwtAinJxqWPvV3DqBVapDWAsSEZjjmbP` + +Artifact regeneration: +- Rebuilt the changed SBF binaries directly: + +```bash +cd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/hyperbet-solana/anchor +cargo build-sbf --manifest-path programs/gold_perps_market/Cargo.toml +cargo build-sbf --manifest-path programs/lvr_amm/Cargo.toml +``` + +- Updated the Anchor target IDL/type address metadata to the rotated IDs. +- Synced those IDLs/types into downstream PM consumers: + +```bash +bun run --cwd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/hyperbet-solana sync:anchor-artifacts +``` + +Validation: +- [packages/hyperbet-chain-registry/tests/chainRegistry.test.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-chain-registry/tests/chainRegistry.test.ts) + - passed: `19/19` +- [packages/hyperbet-solana/tests/deployments.test.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/tests/deployments.test.ts) + - passed: `3/3` +- Full mixed-mode Solana devnet preflight: + +```bash +SOLANA_EXPECTED_AUTHORITY=4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya \ +SOLANA_EXPECTED_UPGRADE_AUTHORITY=4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya \ +bun run --cwd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/hyperbet-solana deploy:preflight:devnet +``` + +Preflight result: +- passed with warnings only for the PM local keypair mismatch wording +- `fight_oracle` + - upgrade mode + - on-chain authority matched `4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya` +- `gold_clob_market` + - upgrade mode + - on-chain authority matched `4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya` +- `lvr_amm` + - fresh-deploy mode + - rotated local keypair now matches manifest +- `gold_perps_market` + - fresh-deploy mode + - rotated local keypair now matches manifest + +### Step 14. Attempt real devnet deploy under the old authority + +Status: +- Blocked by missing local upgrade-authority key + +Deploy attempt: + +```bash +ANCHOR_WALLET=/Users/mac/.config/solana/hyperscape-keys/deployer.json \ +SKIP_BUILD=1 \ +bun run --cwd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/hyperbet-solana anchor:deploy:devnet +``` + +Observed wallet identity: +- local wallet file on disk resolved to: + - `RFXuMwGJ4Rm9mYcYLstWX8ANnZxV25eyfd4ZdMeBY1u` +- on-chain PM upgrade authority remained: + - `4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya` + +Deploy result: +- failed immediately on the first PM upgrade: + +```text +Program's authority Some(4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya) does not match authority provided RFXuMwGJ4Rm9mYcYLstWX8ANnZxV25eyfd4ZdMeBY1u +``` + +Local authority-key search: +- searched `/Users/mac/.config/solana` +- only local Solana key material found: + - `RFXuMwGJ4Rm9mYcYLstWX8ANnZxV25eyfd4ZdMeBY1u /Users/mac/.config/solana/RFXuMwGJ4Rm9mYcYLstWX8ANnZxV25eyfd4ZdMeBY1u.json` + - `RFXuMwGJ4Rm9mYcYLstWX8ANnZxV25eyfd4ZdMeBY1u /Users/mac/.config/solana/hyperscape-keys/deployer.json` + - `RFXuMwGJ4Rm9mYcYLstWX8ANnZxV25eyfd4ZdMeBY1u /Users/mac/.config/solana/id.json` + +Conclusion: +- The repository is now prepared for the intended Solana mixed upgrade/fresh-deploy flow. +- The remaining blocker is external to the repo: + - the actual local keypair for `4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya` is not present on this machine. +- Without that key, PM upgrades cannot proceed, and therefore the full Solana devnet promotion cannot complete. + +### Step 15. Switch Solana Stage-A to strict new-wallet-only funding + +Status: +- Complete + +Decision: +- Abandoned the mixed old-authority Solana model for Stage-A. +- Standardized on the new Stage-A Solana deployer only: + - `B6rVRCTCUxWQ5fmT1fboPsnbuMuwoKpWSCBK3NHbs83w` + +Bootstrap funding actions: +- The CI-held legacy Solana deployer was used only as a funding source, not as the Stage-A authority. +- Updated [fund-stage-a-wallets.yml](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/.github/workflows/fund-stage-a-wallets.yml) to: + - install Solana CLI on the runner + - support `chain=solana` + - support `recipient=ALL` for full-balance sweep + +GitHub Actions run: +- `23597777780` + +Funding transfer: +- from legacy CI-held Solana deployer: + - `4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya` +- to new Stage-A Solana deployer: + - `B6rVRCTCUxWQ5fmT1fboPsnbuMuwoKpWSCBK3NHbs83w` +- signature: + - `4Cc3K1hxGA6d8BkhoTgfsqxzRHfUWnC1j7N34zTLRt3YFZzcYHosz75WbpXBwNSGGiHJMaHPjaed82pn43XXmQnL` + +Confirmed balances after sweep: +- old legacy deployer: + - `0 SOL` +- new Stage-A deployer: + - `9.799917 SOL` + +Additional funding state before final deploy: +- Observed Stage-A deployer balance: + - `14.798917 SOL` +- This was sufficient for the remaining three Solana program deploys plus init/freeze. + +### Step 16. Deploy all four Solana Stage-A programs under the new wallet + +Status: +- Complete + +Build note: +- After the machine reboot, `anchor build` completed successfully again. +- The subsequent deploy used `SKIP_BUILD=1` because the SBF binaries and generated artifacts were already up to date. + +Primary deploy command: + +```bash +export PATH="/opt/homebrew/bin:/usr/local/bin:/Users/mac/.bun/bin:/Users/mac/.local/share/solana/install/active_release/bin:$PATH" +source /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/keys/stage-a/export-stage-a.sh +export SOLANA_RPC_URL="https://solana-devnet.g.alchemy.com/v2/h85R-i8JMJTM3RRVgxLza" +export SKIP_BUILD=1 +cd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/packages/hyperbet-solana/anchor +bash scripts/deploy-programs.sh devnet +``` + +Deploy transport notes: +- Alchemy devnet RPC worked reliably for program deployment after public devnet returned repeated `AlreadyProcessed` write failures. +- `fight_oracle` already matched the current binary and was skipped. +- No stale Stage-A buffers remained after cleanup. + +Final deployed / confirmed Stage-A Solana program IDs: +- `fight_oracle` + - `GFdnu7kUnZGiXh4ejWiJSBCUxvq4UfdEeUv9jjFzr5EM` + - already matched current binary +- `gold_clob_market` + - `3QUVoaKJqo1rg9eXe7vyFewJrY75NWdtH8JZfvTb79Uy` + - deploy signature: + - `4jQHApQGyUSAeHRyBBkcTdBK253TmsjLVJW8YjysztndhR5U9jSb51FJWW5P6XjjbYFPv7zB144JciTE64x9mSa9` +- `lvr_amm` + - `12E8Lz5w8Qxyj8Fh6LgsCgPDQNJMCLMV1y43LhPrH66w` + - deploy signature: + - `jUgwWfhNmP21eY5onNBAQ6ZdjRRVL6D7ppqQXrd65ajPMRTCK5KmvzRt9riJgkLb2k4tPnwa5yfPyeoVw65CbWK` +- `gold_perps_market` + - `BFbmQbSbf3R6fMDdXKMKQZCTyMhMs9MCcjAhGDBLETXS` + - deploy signature: + - `3AxDvrrRa6zkiRAYPFsATMNkq2kMRF7W2AxuSTVLuXReCi1rzsFBhCUJLnGv1X9wNpbU1LYMm1LYVpFokbgmvCzj` + +Verified upgrade authority after deploy: +- all four programs: + - `B6rVRCTCUxWQ5fmT1fboPsnbuMuwoKpWSCBK3NHbs83w` + +### Step 17. Initialize, freeze, and verify the Solana full-product deployment + +Status: +- Complete + +First init/freeze attempt: +- Used Alchemy devnet RPC. +- On-chain transaction landed, but local confirmation timed out because the endpoint did not support `signatureSubscribe` over the derived websocket path. +- Timed-out signature: + - `4jTRujwDdHzg8dqZUoLg6QrSD3U3SzK3JuVFLvV48QrdjdV2GUvVGHXYJJ2Xgmrm3PJHLB8StWsFRtRQaV9dZAXw` +- Transaction status was later confirmed finalized on-chain. + +Successful init/freeze command: + +```bash +export PATH="/opt/homebrew/bin:/usr/local/bin:/Users/mac/.bun/bin:/Users/mac/.local/share/solana/install/active_release/bin:$PATH" +source /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/keys/stage-a/export-stage-a.sh +unset SOLANA_RPC_URL +cd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet +node --import tsx packages/hyperbet-solana/scripts/init-pm-config.ts --cluster devnet --freeze --out .ci-artifacts/stage-a/solana-init-devnet.json +``` + +Init/freeze artifact: +- [.ci-artifacts/stage-a/solana-init-devnet.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/.ci-artifacts/stage-a/solana-init-devnet.json) + +Key PDAs and receipts: +- oracle config: + - `5basW2tPtGYVQLFd4rHNyN4rHrLqbRi5BGBeB8R5MBg2` +- market config: + - `G6SEueAJsTxSbwCSEdYFAbhAXJkdypQ1ZLZFEa2ZAaFM` +- AMM admin state: + - `3AnE6vCHYw3wo7KDanjy7tm51aPNsMSuZpBxWz14HSPS` +- AMM config: + - `3eFbaqGdgsacaqwjTABzC6YJgDkgDNEbDCJDx7xXWBBR` +- perps config: + - `2GKDScykvs22Uie6zC4iKnM5jZyAy1m7mPqKahvpaQEU` +- freeze oracle tx: + - `4y7z65ARWA1hi9qomFpGvsQTX7kMVEu7eBWz9Q1gozd8XNuhwJGkGC7kTphEvgU6USjKQYBof1GobrX592aTsZPx` +- freeze market tx: + - `28osBuQUnW57qLNtqLdFwzzBG6LAwddMUSjavRJHfmhJvsz2UEnihMDhm7jKkms15B4NNzfhdsjUCNumUDgfa69Y` +- freeze AMM tx: + - `3wrADkbgimSnzX5Pj3bzgZSvuNRWvARS72u23jCbzifKuDgs9hWbxHdNV6KJLz2BpFPzMyM2mMzdyEAShZJLZHzH` +- freeze perps tx: + - `2bakr3e2jpHiuqEDSYGEr98PUnEphELubiKHh23KZbssQEq6jKTZSSNoicYhRan4nDGYJKpeZYtu7eouXndfQ3mM` + +Successful verify command: + +```bash +export PATH="/opt/homebrew/bin:/usr/local/bin:/Users/mac/.bun/bin:/Users/mac/.local/share/solana/install/active_release/bin:$PATH" +source /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet/keys/stage-a/export-stage-a.sh +unset SOLANA_RPC_URL +cd /Volumes/OWC\ Envoy\ Pro\ FX/Work/hyperbet +node --import tsx packages/hyperbet-solana/scripts/verify-deployment.ts --cluster devnet --out .ci-artifacts/stage-a/solana-devnet.json +``` + +Verifier fix required: +- [verify-deployment.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/scripts/verify-deployment.ts) initially false-failed all upgrade-authority checks because it invoked `solana program show` with the original Stage-A wallet path containing spaces. +- Patched the verifier to stage the signer into a temp no-space path before shelling out to Solana CLI. + +Final verify artifact: +- [.ci-artifacts/stage-a/solana-devnet.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/.ci-artifacts/stage-a/solana-devnet.json) + +Final verify result: +- `failures: []` +- `warnings: []` +- all four program upgrade authorities matched: + - `B6rVRCTCUxWQ5fmT1fboPsnbuMuwoKpWSCBK3NHbs83w` + +## Current Blockers (Updated Again) + +Known remaining blockers before full non-mainnet signoff: + +1. Shrink or refactor the EVM AMM `Router` so BSC and AVAX AMM can deploy under the EVM max code-size limit. +2. Re-run BSC and AVAX AMM deploys and then re-run full EVM verification so the canonical receipts include AMM addresses. +3. Provision GitHub `staging` with the required `HYPERBET_*_STAGING_*` vars and secrets. +4. Run staged proof and staged soak after the EVM AMM lane and GitHub `staging` provisioning are complete. + +## Step 18. Direct Testnet Acceptance Harness + +Status: +- In progress + +What changed: +- Added local testnet/devnet acceptance env resolution in [testnet-acceptance-env.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/scripts/testnet-acceptance-env.ts). +- Added direct non-mainnet canary wrapper in [run-stage-a-direct-canaries.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/scripts/run-stage-a-direct-canaries.sh). +- Added local acceptance keeper manager in [manage-stage-a-acceptance-services.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/scripts/manage-stage-a-acceptance-services.sh). +- Patched the EVM direct canary in [staged-proof-evm-common.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/scripts/staged-proof-evm-common.ts) to: + - use matched PM trades instead of one-sided order placement + - use chain-only PM lifecycle checks rather than keeper lifecycle polling + - fail hard on reverted EVM receipts instead of assuming a mined hash means success +- Patched the Solana direct canary in [staged-proof-solana.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/keeper/src/staged-proof-solana.ts) to: + - use the Stage-A `ANCHOR_WALLET` authority for privileged PM/perps/AMM calls + - derive and initialize the CLOB market PDA directly instead of relying on keeper-discovered lifecycle state + - prefund the CLOB vault PDA to rent-exempt before placing PM orders + - derive the perps oracle spot index from the live frozen config range instead of a stale hardcoded default + +Current direct-canary results: +- BSC: + - green + - artifact: [.ci-artifacts/stage-a/direct-canaries/bsc.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/.ci-artifacts/stage-a/direct-canaries/bsc.json) +- AVAX: + - green in isolated acceptance runs after fixing the AVAX RPC typo and hardening EVM receipt handling + - latest successful isolated run exercised PM matched trade, perps open/close, and AMM create/buy on AVAX Fuji +- Solana: + - PM lane advanced from authority failure to full direct chain-owned market creation + - PM order placement and claim path no longer depend on keeper lifecycle discovery + - current blocker moved to perps funding, not PM wiring + +Latest Solana blocker: +- The frozen deployed perps config requires: + - `minMarketInsuranceLamports = 12000000000` + - equivalent to `12 SOL` +- The direct canary now reaches `depositInsurance`, but local balances are insufficient to satisfy the on-chain minimum. + +Confirmed local devnet balances: +- Stage-A deployer `B6rVRCTCUxWQ5fmT1fboPsnbuMuwoKpWSCBK3NHbs83w`: `0.97181108 SOL` +- Stage-A canary `49TkQJPeK8wgCfK86imb91B7C5Jjp6QCc4gWD1ELjWwP`: `1.999946 SOL` +- Stage-A market maker `6kVjz7xYE4UbmtsAsPLX9SbU8GR66ExkJoAex8bfJf2Q`: `0.99381344 SOL` +- Stage-A oracle authority `6LBgqsGHCqSyQVwCPEtxTxRwhAvwMAJ5PYhWjQo3xdn4`: `0.25 SOL` +- Additional local wallet `RFXuMwGJ4Rm9mYcYLstWX8ANnZxV25eyfd4ZdMeBY1u`: `5 SOL` + +Aggregate funding reality: +- new-wallet-only total available for Solana acceptance: + - `4.21557052 SOL` +- including the extra local `RFX...` wallet: + - `9.21557052 SOL` +- shortfall versus the frozen perps insurance minimum: + - `2.78442948 SOL` +- practical remaining requirement: + - at least `2.8 SOL` more before Solana perps can be exercised end-to-end + +Updated blockers: +1. Acquire at least `2.8 SOL` more on devnet for local Solana acceptance funding. +2. Re-run the Solana direct canary once the perps insurance minimum can be satisfied. +3. After direct canaries are green on all three chains, move to browser-driven local user testing on top of the deployed Stage-A chains. + +### Step 18a. Additional local Solana funding from RFX + +Status: +- Complete + +Action: +- Swept the remaining local devnet SOL from: + - `RFXuMwGJ4Rm9mYcYLstWX8ANnZxV25eyfd4ZdMeBY1u` +- into the Stage-A Solana deployer: + - `B6rVRCTCUxWQ5fmT1fboPsnbuMuwoKpWSCBK3NHbs83w` + +Transfer receipt: +- signature: + - `5ya16DSpBJfBtRua8MBWna1PLArvEhBxacw7K9XmJLPtcicEQrFoVxixPWSPwXyrGsRwQmg6b7cUbQkx69yeMRtg` + +Post-transfer balances: +- `B6rVRCTCUxWQ5fmT1fboPsnbuMuwoKpWSCBK3NHbs83w`: + - `5.97180608 SOL` +- `RFXuMwGJ4Rm9mYcYLstWX8ANnZxV25eyfd4ZdMeBY1u`: + - `0 SOL` + +Recomputed funding reality after the sweep: +- Stage-A deployer: + - `5.97180608 SOL` +- Stage-A canary: + - `1.999946 SOL` +- Stage-A market maker: + - `0.99381344 SOL` +- Stage-A oracle authority: + - `0.25 SOL` +- aggregate local devnet SOL still available: + - `9.21556552 SOL` +- remaining shortfall versus the frozen perps insurance minimum: + - `2.78443448 SOL` + +### Step 18b. Solana perps insurance rerun after failed user top-up + +Status: +- Blocked + +Action: +- Rechecked the Stage-A Solana actor balances after the attempted extra user top-up. +- Queried recent signatures for the Stage-A deployer on both Helius and public devnet RPC. +- Re-ran the direct Solana canary with fresh duel inputs against: + - `https://devnet.helius-rpc.com/?api-key=dd4ea427-6b5e-4c12-b8f9-e157dfda064a` + +Observed balances before the clean rerun: +- Stage-A deployer `B6rVRCTCUxWQ5fmT1fboPsnbuMuwoKpWSCBK3NHbs83w`: + - `1.199995 SOL` +- Stage-A canary `49TkQJPeK8wgCfK86imb91B7C5Jjp6QCc4gWD1ELjWwP`: + - `0.099995 SOL` +- Stage-A market maker `6kVjz7xYE4UbmtsAsPLX9SbU8GR66ExkJoAex8bfJf2Q`: + - `0.55124948 SOL` +- Stage-A oracle authority `6LBgqsGHCqSyQVwCPEtxTxRwhAvwMAJ5PYhWjQo3xdn4`: + - `0.25 SOL` + +Observed balances after the insurance rerun drained the remaining usable local reserves: +- Stage-A deployer `B6rVRCTCUxWQ5fmT1fboPsnbuMuwoKpWSCBK3NHbs83w`: + - `1.17761348 SOL` +- Stage-A canary `49TkQJPeK8wgCfK86imb91B7C5Jjp6QCc4gWD1ELjWwP`: + - `0.099941 SOL` +- Stage-A market maker `6kVjz7xYE4UbmtsAsPLX9SbU8GR66ExkJoAex8bfJf2Q`: + - `0.04690172 SOL` +- Stage-A oracle authority `6LBgqsGHCqSyQVwCPEtxTxRwhAvwMAJ5PYhWjQo3xdn4`: + - `0.009995 SOL` + +Exact current blocker: +- The clean rerun now fails with the explicit error: + - `solana perps insurance shortfall: need 11261843800 lamports (11.261843800 SOL) more after local wallet aggregation` +- This confirms the extra attempted user top-up did not arrive as usable balance on the Stage-A funding pool, and the remaining local wallets have now been drained down to their configured acceptance reserves. + +Instrumentation added: +- Patched [staged-proof-solana.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/keeper/src/staged-proof-solana.ts) to log: + - current perps insurance before funding + - target insurance + - remaining insurance gap + - per-source balance, reserve, and available funding + - per-source `depositInsurance` transaction signatures + +Updated acceptance reality: +1. BSC direct canary remains green. +2. AVAX direct canary remains green. +3. Solana PM lane remains wired and usable. +4. Solana perps still cannot complete without another `11.261843800 SOL` of usable devnet funding. + +### Step 18c. Solana direct canary root-cause fixes and green run + +Status: +- Complete + +Root causes identified: +1. The acceptance harness was rotating duel keys on every rerun, which created fresh isolated Solana perps market IDs and made earlier insurance deposits appear to be lost. +2. The Solana canary did not honor plain local `CANARY_*` override env names, so manual reruns were silently ignoring the intended perps market override. +3. The perps open/close flow was racing the frozen `maxOracleStalenessSeconds = 5` requirement by sending `updateMarketOracle` and `modifyPosition` as separate transactions. +4. The perps margin default was too brittle because it did not account for trade fees, so a nominal min-margin trade could still fail `InvalidMargin`. +5. The AMM acceptance default was too heavy for the remaining local authority balance because `init_bet_account` transfers the full initial bet reserve from the authority. +6. The Solana canary result JSON still referenced a removed `market` variable after the flow changes. + +Fixes applied: +- Patched [run-stage-a-direct-canaries.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/scripts/run-stage-a-direct-canaries.sh) to persist a sticky acceptance duel under: + - `keys/stage-a/acceptance/duel.env` +- Patched [staged-proof-solana.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/keeper/src/staged-proof-solana.ts) to: + - honor bare `CANARY_*` env overrides in addition to legacy names + - accept a fixed `CANARY_PERPS_MARKET_ID` + - log live perps insurance state/source funding when diagnosing failures + - reuse the already-funded perps market instead of minting fresh isolated insurance requirements + - bundle `updateMarketOracle` and `modifyPosition` into the same transaction for perps open/close + - auto-pad perps margin above `min_margin_lamports + fees` + - lower the default AMM initial liquidity for acceptance from `1 SOL` to `0.01 SOL` + - fix the final Solana result assembly to reference the actual PM market PDA + +Confirmed funded perps market reused: +- market account: + - `9SaxjYKYLTCHnWAW6J27bG4VWCiaoTW4cwJ612P4fyfa` +- market id: + - `4893965445` +- on-chain `insurance_fund`: + - `12 SOL` + +Successful direct Solana canary artifact: +- [.ci-artifacts/stage-a/direct-canaries/solana.json](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/.ci-artifacts/stage-a/direct-canaries/solana.json) + +Result: +1. BSC direct canary: green. +2. AVAX direct canary: green. +3. Solana direct canary: green. +4. The protocol-level precondition for browser-driven testnet acceptance is now satisfied on all deployed Stage-A chains. + +### Step 18d. Synthetic browser-to-chain acceptance completed on BSC + +Status: +- Complete + +What changed: +1. Patched [market-flows.e2e.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-bsc/app/tests/e2e/market-flows.e2e.ts) so: + - the matched YES/NO PM flow still uses the stronger `0.001` setup that proves two-sided browser trading + - recovery uses the lighter `0.0005` sell seed to avoid burning matcher gas on a non-matching setup leg + - cancel/refund uses an isolated `E2E_CANCEL_PREDICTION_AMOUNT = 0.00025` because the acceptance bar there is refund semantics, not order size +2. Rebalanced remaining BSC gas locally across Stage-A wallets instead of blocking on another faucet/CI funding cycle. +3. Completed the remaining targeted browser reruns on the deployed BSC testnet chain. + +Confirmed browser tx evidence: +- recovery YES: + - `0x79c6a2a7840ad0736b6d85b45fab05296735d67d7655e6ed9b081c040beb35bd` +- cancel YES: + - `0xc0163a00de2f03133ed1f7b8f58bf5d6611963336bfda418a1d4d95bc390b670` +- cancel refund claim: + - `0x73277718f9c347752176315d122544007975fbfae0ab64271582383be617bdb6` + +Local-only BSC gas rebalances used to finish the lane: +- admin -> matcher: + - `0xd62d595c390f6dd2c1765d94d2f75407754294f5681e0c66fd731969014918fe` +- market operator -> canary: + - `0xea63e4529b47449944f77a7459bca4009be31c4645dd90503c3a5fcc967a8ff8` +- deployer -> market operator: + - `0x6067869ed15ead376db9c90de5939861274492c49261d62f5febdb0f244da00e` +- admin -> reporter: + - `0x00a11977713338a599502b2c195a5901e98a1a347571981fd31d26535e23be68` + - `0xbf5676474f8bf88a863f2365ac079e77a4412faeec5007a87518c438ac9fc004` + - `0xc2d46a7910f761537f41d5db75cc0bb7fc9aed0c0baaee5454b04654dd1ab869` +- admin -> finalizer: + - `0x5994c081c6e26689ca8263dbe9a4b0231579543b694ed55c3431e3e3cdd2502b` +- finalizer -> canary: + - `0xc52f6656b6d174bcd06c5edf714e43586ddb1d987de13f546b452eeb73697fff` +- finalizer -> matcher: + - `0x329ba75b6ab59a6d9a8885f6517fe6f0b246d36a82c5bc308f71c255e7e94e3c` +- treasury -> canary: + - `0x41bc3035385d097c6c1a1daa2b0dc1b79bc4d21aabc0ace43c4f194d803360d6` + - `0x8dd47911afb4d384043d9eec48caf8df79bd63542032ea706aefbf8ba07eeed9` +- market maker -> reporter: + - `0xc0f1e68ca5cf2ed891edb60b860173c278c3e34d4c3b64f743d0c0b47d9f7f36` + - `0xfe0af50a8a54d9c9647f35e8a2597ef1c7f44d3281d2a174b529f8f204fc4dcd` +- market maker -> matcher: + - `0xc1e9085f3666d7127259c80a24e2acfe675096cffabead799ee50abb037f5afa` + - `0x39615ef74c5aa234a4dbb2a85e5dca8195c1b0de18131d1f12fb24e8f88ce24c` + +Result: +1. BSC synthetic browser PM lane is green on the deployed Stage-A contracts. +2. AVAX synthetic browser PM lane was already green. +3. Solana synthetic browser lane remains green except for the explicitly time-gated matured-claim variant. +4. The next phase is swapping the duel source from `synthetic_publish` to `real_hyperscapes`. + +### Step 18e. BSC real Hyperscapes PM write path green + +Status: +- Complete + +What changed: +1. Patched the shared public fixture builder in [stage-a-public-fixtures.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-bsc/app/tests/e2e/stage-a-public-fixtures.ts) so real EVM write runs can use a lightweight Solana seed fixture instead of paying the full Solana bootstrap cost. +2. Patched the BSC and AVAX public runners in: + - [run-e2e-public.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-bsc/app/scripts/run-e2e-public.sh) + - [run-e2e-public.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-avax/app/scripts/run-e2e-public.sh) + so real-duel PM write runs use explicit setup scope selection and avoid the live Hyperscapes app-port collision by moving the browser app to `4190` on BSC and `4191` on AVAX when needed. +3. Patched the BSC market-flow helper in [market-flows.e2e.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-bsc/app/tests/e2e/market-flows.e2e.ts) so a prepared live duel remains authoritative at test time instead of re-failing on the full fresh-duel threshold after setup has already selected it. +4. Measured the live duel exposure directly from the local Hyperscapes stream and confirmed the game exposes a usable `duelKeyHex` with about `164.8s` remaining in the betting window, so the shared setup threshold must be lower than the write-path freshness target. + +Confirmed real-duel browser tx evidence: +- YES order: + - `0x97bd75b787b8d488a7b0ad1d794efd7f6718d63abf0378440e9488a460c2aecd` +- NO order: + - `0xb98edc456cd953d84266516f642877275444a78c41bf901fdda3447332daafd5` + +Confirmed targeted run: +- command: + - `E2E_DUEL_SOURCE=real_hyperscapes E2E_PUBLIC_SETUP_SCOPE=evm_write E2E_SKIP_PUBLIC_SETUP=true E2E_ACCEPTANCE_CHAINS=bsc bash packages/hyperbet-bsc/app/scripts/run-e2e-public.sh tests/e2e/market-flows.e2e.ts -g "evm predictions place YES and NO orders on a fresh live market"` +- result: + - `1 passed (24.3s)` + +Result: +1. The BSC real Hyperscapes PM YES/NO browser write path is green against the deployed Stage-A BSC testnet market. +2. The remaining real-duel browser work is now AVAX PM, Solana PM/CLOB, restart recovery, and soak. + +### Step 18f. AVAX real Hyperscapes PM write path green + +Status: +- Complete + +What changed: +1. Patched [fund-stage-a-evm-wallets.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/scripts/fund-stage-a-evm-wallets.ts) so the stage-a funding utility accepts both `--chain=value` and `--chain value` plus the same dual form for `--profile`, which fixed the AVAX runner accidentally funding both BSC and AVAX on every AVAX-only invocation. +2. Ported the prepared-live-market real-duel helper pattern to [market-flows.e2e.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-avax/app/tests/e2e/market-flows.e2e.ts), including matcher-only seed liquidity and prepared-state reuse instead of second-pass live-duel discovery. + +Confirmed real-duel browser tx evidence: +- YES order: + - `0x3af62ab49f66739d81746b21ebcb9ceccdc68b859e42766343c21f5b35c93532` +- NO order: + - `0x261cdba97db07805cd056cf8c5915fe2668f021a5b499da39eb9a771ca6c2417` + +Confirmed targeted run: +- command: + - `E2E_DUEL_SOURCE=real_hyperscapes E2E_PUBLIC_SETUP_SCOPE=evm_write E2E_ACCEPTANCE_CHAINS=avax bash packages/hyperbet-avax/app/scripts/run-e2e-public.sh tests/e2e/market-flows.e2e.ts -g "evm predictions place YES and NO orders on a fresh live market"` +- result: + - `1 passed (24.8s)` + +Result: +1. The AVAX real Hyperscapes PM YES/NO browser write path is green against the deployed Stage-A AVAX Fuji market. +2. The remaining real-duel browser work is now Solana PM/CLOB, restart recovery, and soak. + +### Step 18g. Solana real Hyperscapes PM write path green; dedicated CLOB UI blocked on fresh setup funding + +Status: +- In progress + +What changed: +1. Patched the Solana public runner in [run-e2e-public.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/app/scripts/run-e2e-public.sh) so `real_hyperscapes` mode no longer hard-exits and instead: + - points the keeper at live Hyperscapes stream state + - disables synthetic stream publish in live mode + - exports the live game HTTP/WS endpoints into the browser runtime + - supports `E2E_SKIP_PUBLIC_SETUP=true` for prepared-state reuse +2. Patched the shared Solana public fixture builder in [stage-a-public-fixtures.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-bsc/app/tests/e2e/stage-a-public-fixtures.ts) so the Solana lane now writes live duel identity and timing into `state.json` when `real_hyperscapes` is selected, instead of always generating a synthetic duel. +3. Patched the Solana API seed and app-tabs coverage in: + - [seed-api-local.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/app/tests/e2e/seed-api-local.ts) + - [app-tabs-and-apis.e2e.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/app/tests/e2e/app-tabs-and-apis.e2e.ts) + so live mode no longer calls `/api/streaming/state/publish` and read-only assertions use the live prepared phase. +4. Patched the Solana PM and dedicated CLOB UI test helpers in: + - [market-flows.e2e.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/app/tests/e2e/market-flows.e2e.ts) + - [solana-clob-ui.e2e.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/app/tests/e2e/solana-clob-ui.e2e.ts) + so `real_hyperscapes` mode consumes prepared live-duel state instead of synthetic publish. + +Confirmed targeted run: +- command: + - `E2E_DUEL_SOURCE=real_hyperscapes bash packages/hyperbet-solana/app/scripts/run-e2e-public.sh tests/e2e/market-flows.e2e.ts -g "solana predictions place YES and NO orders and stage a proposed winner claim"` +- result: + - `1 passed (16.4s)` + +Confirmed prepared live fixture state from the passing PM run: +- duel id: + - `streaming-446f1f86-8b7b-47b0-9f78-1d19618a6575` +- duel key: + - `a746c87eda941725693ba9f92f1fe2142232d0de898ea9929cc4859b463aed28` +- market: + - `H1Fi48L6WFemyeyRSj9daeCqJrcoKzviFEMPrvwwX7Gm` + +Current blocker: +1. A fresh Solana public setup is currently underfunded for the next dedicated CLOB rerun. +2. Confirmed current balances: + - deployer `B6rVRCTCUxWQ5fmT1fboPsnbuMuwoKpWSCBK3NHbs83w`: `0.040000000 SOL` + - canary `49TkQJPeK8wgCfK86imb91B7C5Jjp6QCc4gWD1ELjWwP`: `0.044792000 SOL` + - market maker `6kVjz7xYE4UbmtsAsPLX9SbU8GR66ExkJoAex8bfJf2Q`: `0.009995000 SOL` + - oracle authority `6LBgqsGHCqSyQVwCPEtxTxRwhAvwMAJ5PYhWjQo3xdn4`: `0.004995000 SOL` +3. The last failed fresh setup transfer attempted to move `0.060208000 SOL` from the bootstrap authority, so the immediate shortfall is about `0.020208 SOL`. +4. Reusing public setup without a fresh live-duel rebuild is not a clean acceptance result for the dedicated CLOB rerun because the active live duel can rotate away from the prepared state before the CLOB test starts. + +Result: +1. The Solana real Hyperscapes PM browser write path is green against a prepared live duel without synthetic publish. +2. The Solana dedicated CLOB UI live-duel lane is implemented but currently blocked on a small bootstrap-wallet SOL top-up for the next fresh setup cycle. + +### Step 18h. Solana dedicated CLOB UI real Hyperscapes lane green + +Status: +- Complete + +What changed: +1. Patched the acceptance env resolver in [testnet-acceptance-env.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/scripts/testnet-acceptance-env.ts) so Solana acceptance runtime now exposes an explicit RPC websocket URL via `rpcWsUrl`, preferring: + - `SOLANA_ALCHEMY_WS_URL` + - `ALCHEMY_SOLANA_WS_URL` + - `SOLANA_RPC_WS_URL` + - `SOLANA_WS_URL` + - `ANCHOR_WS_URL` + and only deriving a websocket URL when no explicit override exists. +2. Patched the shared fixture writer in [stage-a-public-fixtures.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-bsc/app/tests/e2e/stage-a-public-fixtures.ts) so Solana prepared state now records `solanaWsUrl` and writes `VITE_SOLANA_WS_URL` from the resolved acceptance runtime instead of reconstructing it ad hoc. +3. Patched [test-anchor.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/anchor/tests/test-anchor.ts) to export a reusable polling provider that confirms both legacy and versioned transactions by polling, rather than depending on remote Solana websocket `signatureSubscribe` behavior. +4. Patched [solana-clob-ui.e2e.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/app/tests/e2e/solana-clob-ui.e2e.ts) so the dedicated CLOB lane now: + - uses the explicit Solana RPC websocket URL when available + - funds the seeded ask-liquidity maker from the bootstrap authority before placing the order + - gives the seeded maker enough lamports to cover both the live ask reservation and PDA/account rent during order creation +5. Patched the Solana public runner in [run-e2e-public.sh](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/app/scripts/run-e2e-public.sh) so the shared live-duel selector defaults to `E2E_LIVE_DUEL_MIN_WINDOW_MS=60000`, which matches the dedicated CLOB lane’s prepared-market freshness requirement and avoids wasting time on a stricter shared threshold. +6. Added the explicit Alchemy Solana websocket endpoints to the local gitignored acceptance env at [/.env.stage-a.testnet.local](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/.env.stage-a.testnet.local): + - `SOLANA_ALCHEMY_WS_URL=wss://solana-devnet.g.alchemy.com/v2/h85R-i8JMJTM3RRVgxLza` + - `SOLANA_WS_URL=wss://solana-devnet.g.alchemy.com/v2/h85R-i8JMJTM3RRVgxLza` + +Confirmed targeted run: +- command: + - `E2E_DUEL_SOURCE=real_hyperscapes bash packages/hyperbet-solana/app/scripts/run-e2e-public.sh tests/e2e/solana-clob-ui.e2e.ts` +- result: + - `1 passed (19.4s)` + +Confirmed prepared live fixture state from the passing dedicated CLOB run: +- duel id: + - `streaming-790eaec0-3902-437f-b042-a15b71941682` +- duel key: + - `047be087f107bf9f656e22db0f9fb5de5d5a4ebb4bf3f262ce7eae1b1af6582c` +- market: + - `28yhqN8G8uUry4GtwYMn6xrrotPGps95sZCRM4oJNnp8` + +Result: +1. The dedicated Solana CLOB browser lane is now green in `real_hyperscapes` mode against a fresh prepared live duel. +2. The remaining acceptance work is now real-duel recovery, the time-gated matured Solana claim lane, and the 25-minute soak. + +### Step 18i. Real-duel keeper-restart recovery green on BSC, AVAX, and Solana + +Status: +- Complete + +What changed: +1. Reused the prepared-live-duel real-mode setup for the BSC and AVAX recovery lanes, so the recovery tests now restart the keeper against the same real Hyperscapes market-materialization path used by the green PM write tests. +2. Patched [market-flows.e2e.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/app/tests/e2e/market-flows.e2e.ts) so the Solana recovery test no longer hard-skips `real_hyperscapes` mode and only attempts the synthetic-only `solanaProxy` restart when the suite is actually running against the synthetic localnet topology. +3. Reused the corrected Solana public runner and live-duel selector floor so the Solana recovery lane can rebuild a fresh prepared live duel and survive a keeper restart in the real public topology. + +Confirmed targeted runs: +- BSC command: + - `E2E_DUEL_SOURCE=real_hyperscapes E2E_PUBLIC_SETUP_SCOPE=evm_write E2E_ACCEPTANCE_CHAINS=bsc bash packages/hyperbet-bsc/app/scripts/run-e2e-public.sh tests/e2e/market-flows.e2e.ts -g "bsc prediction markets recover after keeper restarts"` +- BSC result: + - `1 passed (36.3s)` +- BSC recovery YES tx: + - `0x3abd521c2b89a1f2be898a89e2c67a52f4f5289bf7170aba80f9a30b3bef2a99` +- AVAX command: + - `E2E_DUEL_SOURCE=real_hyperscapes E2E_PUBLIC_SETUP_SCOPE=evm_write E2E_ACCEPTANCE_CHAINS=avax bash packages/hyperbet-avax/app/scripts/run-e2e-public.sh tests/e2e/market-flows.e2e.ts -g "avax prediction markets recover after keeper restarts"` +- AVAX result: + - `1 passed (36.1s)` +- AVAX recovery YES tx: + - `0x079b2c841a3fb02f272e6640d44ba80b6f2ccf0b98305153e9d7a89daf0bed76` +- Solana command: + - `E2E_DUEL_SOURCE=real_hyperscapes bash packages/hyperbet-solana/app/scripts/run-e2e-public.sh tests/e2e/market-flows.e2e.ts -g "solana open prediction markets recover after keeper and proxy restarts"` +- Solana result: + - `1 passed (32.1s)` +- Solana prepared recovery duel: + - duel id `streaming-4e93cb90-cfe6-4e81-bdf7-7506fdc5e870` + - duel key `6093eb30c3cef001428da36091fd34d37b4477727664137261a636a7da56b606` + - market `GEaMT3VkXSkGGgz1YDnKzW48PzN73u8VEwFHfgSvX4Hj` + +Result: +1. Keeper-restart recovery is now green on all three real-duel browser lanes. +2. The remaining acceptance work is the dedicated Hyperscapes restart recovery drill, the time-gated matured Solana claim lane, and the 25-minute real-duel soak. + +### Step 18j. Real-duel Hyperscapes-restart recovery green on BSC, AVAX, and Solana + +Status: +- Complete + +What changed: +1. Reused the same prepared live duel and market selection contract from the green real-duel PM runs, but restarted `hyperscapes` instead of the keeper so the browser lanes had to recover from a live duel-source interruption rather than a local market cache restart. +2. Reused the shared process-control contract for `hyperscapes` and `hyperscapesClient`, so the browser tests now prove the same duel id and market ref survive a duel-source restart before attempting a new post-restart write. +3. Kept the post-restart validation honest by requiring one new browser action after the restart: + - BSC / AVAX: one new YES order with on-chain position delta + - Solana PM: one new YES order with on-chain position delta + - Solana CLOB: one new order against the rebound live duel + +Confirmed targeted runs: +- BSC command: + - `E2E_DUEL_SOURCE=real_hyperscapes E2E_PUBLIC_SETUP_SCOPE=evm_write E2E_ACCEPTANCE_CHAINS=bsc bash packages/hyperbet-bsc/app/scripts/run-e2e-public.sh tests/e2e/market-flows.e2e.ts -g "bsc prediction markets recover after Hyperscapes restarts"` +- BSC result: + - `1 passed` +- BSC Hyperscapes-restart YES tx: + - `0xa093fc026860c5d5d0f549de4188d32854dd58d6c0773855e4bf964b5f5e3579` +- AVAX command: + - `E2E_DUEL_SOURCE=real_hyperscapes E2E_PUBLIC_SETUP_SCOPE=evm_write E2E_ACCEPTANCE_CHAINS=avax bash packages/hyperbet-avax/app/scripts/run-e2e-public.sh tests/e2e/market-flows.e2e.ts -g "avax prediction markets recover after Hyperscapes restarts"` +- AVAX result: + - `1 passed` +- AVAX Hyperscapes-restart YES tx: + - `0x338b3f3cab3f7684bd06eff84a7484a6395e461abe959035d4939421e299e674` +- Solana PM command: + - `E2E_DUEL_SOURCE=real_hyperscapes bash packages/hyperbet-solana/app/scripts/run-e2e-public.sh tests/e2e/market-flows.e2e.ts -g "solana open prediction markets recover after Hyperscapes restarts"` +- Solana PM result: + - `1 passed` +- Solana CLOB command: + - `E2E_DUEL_SOURCE=real_hyperscapes bash packages/hyperbet-solana/app/scripts/run-e2e-public.sh tests/e2e/solana-clob-ui.e2e.ts -g "prediction market rebinds the same live duel after Hyperscapes restarts"` +- Solana CLOB result: + - `1 passed` + +Result: +1. Hyperscapes-restart recovery is now green on the targeted BSC PM, AVAX PM, Solana PM, and Solana CLOB browser lanes. +2. The remaining acceptance work is the real Solana matured-claim lane and the bounded observe-only soak. + +### Step 18k. Real Solana proposal-stage fixture recorded for the matured-claim lane + +Status: +- Complete + +What changed: +1. Reused a fresh `real_hyperscapes` Solana public setup and let the real proposal-stage lane complete the same post-order lock -> propose -> sync path as the synthetic lane, without calling `/api/streaming/state/publish`. +2. Recorded the resulting proposed fixture directly from chain state so the later `E2E_SKIP_PUBLIC_SETUP=true` matured-claim rerun can consume the same duel and market after the dispute window elapses. +3. Added explicit Playwright evidence attachment support for the later matured-claim lane in [market-flows.e2e.ts](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/packages/hyperbet-solana/app/tests/e2e/market-flows.e2e.ts), so the final claim rerun will persist both a screenshot and a structured evidence payload. + +Confirmed targeted run: +- command: + - `E2E_CLUSTER=devnet E2E_DUEL_SOURCE=real_hyperscapes E2E_APP_PORT=4192 bash ./packages/hyperbet-solana/app/scripts/run-e2e-public.sh tests/e2e/market-flows.e2e.ts -g "solana predictions place YES and NO orders and stage a proposed winner claim"` +- result: + - `1 passed (2.3m)` + +Recorded proposed fixture: +- duel id: + - `streaming-177dc378-c195-4faf-a3c2-2ef2e945bf33` +- duel key: + - `9ec21f89f3797aac98a35cb401eeeee6e8c269a749505d3def8ec2f7bd6f5be7` +- market: + - `Eg27CiTYX67SdPFrnqeNbyVZePraZ23jxPA9dATaxXeE` +- duel state: + - `BEZYSNQaFDVzThZ8L66YG7SA9vLgtTuVWN8BEi83mqct` +- market status: + - `locked` +- duel status: + - `proposed` +- pending winner: + - `a` +- proposal signature: + - `4iwM3VNyJ8SWjWugyUGkWbE8R5PGrLQrFXp5Autf2pqmDLJggTgdQa3ZDkFSAHEnsvhFabEaLu3gRtp2EKSB1fih` +- pending proposed at: + - `1774679287` +- dispute window secs: + - `3600` +- finalizable at: + - `1774682887` (`2026-03-28 02:28:07 CDT`) +- canary position: + - `aShares=50000000` + - `bShares=0` + +Result: +1. The real Solana proposal-stage lane now produces a concrete proposed fixture for the canary trader. +2. The only remaining signoff work is the bounded observe-only soak and the matured Solana claim rerun after `finalizableAt`. + +### Step 18l. Real Solana matured-claim lane green against the recorded proposal fixture + +Status: +- Complete + +What changed: +1. Reused the recorded `real_hyperscapes` Solana fixture after `finalizableAt` with `E2E_SKIP_PUBLIC_SETUP=true`, so the claim lane finalized and claimed the exact proposal-stage market instead of rebuilding a new duel. +2. Made the real-mode matured-claim browser lane idempotent for preserved fixtures, so reruns can still recover the same finalize and claim evidence even if the first successful attempt already consumed the claimable balance PDA. +3. Recorded the terminal on-chain evidence directly from the finalized duel state and claim transaction metadata. + +Confirmed targeted run: +- command: + - `E2E_CLUSTER=devnet E2E_DUEL_SOURCE=real_hyperscapes E2E_SKIP_PUBLIC_SETUP=true E2E_REQUIRE_MATURED_SOLANA_WIN_CLAIM=true E2E_APP_PORT=4192 bash ./packages/hyperbet-solana/app/scripts/run-e2e-public.sh tests/e2e/market-flows.e2e.ts -g "solana predictions finalize a matured proposal and claim winnings"` +- result: + - `1 passed (46.1s)` + +Recorded finalized claim evidence: +- duel id: + - `streaming-177dc378-c195-4faf-a3c2-2ef2e945bf33` +- duel key: + - `9ec21f89f3797aac98a35cb401eeeee6e8c269a749505d3def8ec2f7bd6f5be7` +- market: + - `Eg27CiTYX67SdPFrnqeNbyVZePraZ23jxPA9dATaxXeE` +- duel state: + - `BEZYSNQaFDVzThZ8L66YG7SA9vLgtTuVWN8BEi83mqct` +- finalize signature: + - `4tkVyYhgZMjTjzmWdoyZPvam7dMKBHj5Zm7CTVuu31yk4nrKYzkKQupPAyNzHx85FXL9dCvEyeKhZUC4WVL5a7Fy` +- claim signature: + - `2Mopxy5AbnJUHC55VMVeADUcsSVBWLcp9kvBnq41doUGk8GhBmGPZyAcACCfGMC9Qbr9QEcCYPn8mE4CiV2og2bV` +- trader lamport delta: + - `50609720` + +Result: +1. The real Solana matured-claim lane is green against the same recorded proposal-stage fixture. +2. The only remaining signoff work is the bounded observe-only real-duel soak. + +### Step 18m. Bounded observe-only real-duel soak green + +Status: +- Complete + +What changed: +1. Re-ran the local signoff soak through the integrated `real_hyperscapes` path with `PM_SOAK_SIGNOFF_MODE=true`, so the monitor stayed observe-only and failed closed instead of self-healing. +2. Kept repair envs unset and recorded the final artifact bundle from the bounded 25-minute run against the local app + local keeper + sibling Hyperscapes stack + deployed Stage-A chains. +3. Confirmed the corrected soak contract no longer requires an unrealistic eight scored cycles in signoff mode, so the bounded run can pass honestly on observed duel durations. + +Confirmed targeted run: +- command: + - `PM_LOCAL_EVM_MODE=testnet SOLANA_CLUSTER=devnet APP_MODE=testnet HYPERSCAPES_DUEL_FRESH=false PM_E2E_MONITOR=true PM_SOAK_SIGNOFF_MODE=true PM_SOAK_LOCAL_DURATION_MIN=25 OPEN_LOCAL_UI=false ./scripts/run-hyperscapes-pm-local.sh` +- result: + - exited `0` + +Recorded soak artifact: +- artifact root: + - [/Volumes/OWC Envoy Pro FX/Work/hyperbet/output/playwright/pm-soak/2026-03-28T08-03-45-491Z](/Volumes/OWC%20Envoy%20Pro%20FX/Work/hyperbet/output/playwright/pm-soak/2026-03-28T08-03-45-491Z) +- summary: + - `pass=true` + - `signoffMode=true` + - `durationMs=1504787` + - `cyclesObserved=7` + - `scoredCyclesObserved=6` + - `ignoredCyclesObserved=1` + - `driftCyclesObserved=2` + - `bothUiReachable=true` + - `reconcileAttempts=0` + - `incidents=[]` + +Result: +1. The bounded observe-only soak is green against the intended local real-duel signoff topology. +2. Stage-A browser-to-chain signoff on this branch is complete. diff --git a/docs/release/testnet-operations-ledger.md b/docs/release/testnet-operations-ledger.md index d04b22b0..df0bcbf9 100644 --- a/docs/release/testnet-operations-ledger.md +++ b/docs/release/testnet-operations-ledger.md @@ -1,176 +1,129 @@ # Testnet Operations Ledger -Single source of truth for all testnet wallet generation, funding, secret storage, and governance configuration used in the Stage A deployment flow. +> **Historical snapshot:** This ledger preserves the Stage-A wallet, provisioning, and funding record for the branch-era non-mainnet work. Current open-work ownership lives in [tracking-document-map.md](tracking-document-map.md) and [github-project-production-backlog.md](github-project-production-backlog.md). Use this file as evidence and provisioning history, not as the canonical blocker list. ---- +> **TL;DR:** This is the current Stage-A non-mainnet ledger for `Solana devnet`, `BSC testnet`, and `AVAX Fuji`. Repo-level deploy/testnet secrets exist, but GitHub staged proof and staged soak are still blocked because there is no `staging` environment and no `HYPERBET_*_STAGING_*` vars or secrets provisioned yet. Local Stage-A is also still waiting on shared BSC and AVAX token-address inputs for AMM and perps rehearsal. + +Single source of truth for testnet wallet generation, funding, secret storage, +and current provisioning gaps used in the Stage-A deployment flow. ## Wallet Inventory -### EVM Wallets (shared across BSC Testnet, AVAX Fuji) +### EVM Wallets (shared across BSC testnet and AVAX Fuji) | Role | Address | Purpose | |------|---------|---------| -| DEPLOYER | `0x25DFe05ea0d5bb2F96b9D351765CC5E2DB86dCC0` | Deploys contracts via CREATE2, funds other wallets | -| ADMIN | `0x99622633cF1e476C8bD9161f5B9d4F290a1D2Ea1` | DEFAULT_ADMIN_ROLE holder (will be replaced by timelock) | -| REPORTER | `0xe94d0c1bBA64da68310DbfC07149E264E77b58AC` | Oracle REPORTER_ROLE — proposes duel results | -| FINALIZER | `0x17D1495dB7374f1814801275bB9dac84Fcb0079e` | Oracle FINALIZER_ROLE — finalizes after dispute window | -| CHALLENGER | `0x2b073F23C61a420c208963C5C650FB54c82f893a` | Oracle CHALLENGER_ROLE — challenges proposals | -| PAUSER | `0xdCDeC0c831ED7Af279E724fddb127dc6134e5df6` | PAUSER_ROLE — emergency pause on oracle + CLOB | -| MARKET_OPERATOR | `0x99622633cF1e476C8bD9161f5B9d4F290a1D2Ea1` | Creates markets (same as ADMIN for testnet) | -| TREASURY | `0x5c5A3554F12875aBB63a6b8027b9A23C423F5C84` | Receives trading fees | -| MARKET_MAKER | `0x1bC49a0d5232cAc83fe696AB604B0b1E58C54A41` | Receives market maker fees on winnings | -| MULTISIG_SIGNER_1 | `0xFC951Ead43344CaBF775E077dcf3334BAe228730` | Multisig signer (1 of 3) | -| MULTISIG_SIGNER_2 | `0x785fceED2d6ab37e5a22329E2ED496427A58CbE2` | Multisig signer (2 of 3) | -| MULTISIG_SIGNER_3 | `0x62e7028DEe826a2a6F811021a5eAA379713A36C6` | Multisig signer (3 of 3) | +| DEPLOYER | `0x25DFe05ea0d5bb2F96b9D351765CC5E2DB86dCC0` | Deploys contracts via CREATE2 and funds other wallets | +| ADMIN | `0x99622633cF1e476C8bD9161f5B9d4F290a1D2Ea1` | Default admin on testnet | +| REPORTER | `0xe94d0c1bBA64da68310DbfC07149E264E77b58AC` | Oracle reporter | +| FINALIZER | `0x17D1495dB7374f1814801275bB9dac84Fcb0079e` | Oracle finalizer | +| CHALLENGER | `0x2b073F23C61a420c208963C5C650FB54c82f893a` | Oracle challenger | +| PAUSER | `0xdCDeC0c831ED7Af279E724fddb127dc6134e5df6` | Emergency pauser | +| MARKET_OPERATOR | `0x99622633cF1e476C8bD9161f5B9d4F290a1D2Ea1` | Market operator on testnet | +| TREASURY | `0x5c5A3554F12875aBB63a6b8027b9A23C423F5C84` | Fee recipient | +| MARKET_MAKER | `0x1bC49a0d5232cAc83fe696AB604B0b1E58C54A41` | MM fee recipient | +| MULTISIG_SIGNER_1 | `0xFC951Ead43344CaBF775E077dcf3334BAe228730` | Multisig signer | +| MULTISIG_SIGNER_2 | `0x785fceED2d6ab37e5a22329E2ED496427A58CbE2` | Multisig signer | +| MULTISIG_SIGNER_3 | `0x62e7028DEe826a2a6F811021a5eAA379713A36C6` | Multisig signer | ### Solana Wallet | Role | Address | Purpose | |------|---------|---------| -| DEPLOYER / AUTHORITY | `4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya` | Deploys programs, holds config authority and upgrade authority | - ---- - -## Multisig Configuration - -- **Type:** Safe (Gnosis Safe) on EVM chains -- **Threshold:** 3-of-3 (all signers required) -- **Signers:** MULTISIG_SIGNER_1, MULTISIG_SIGNER_2, MULTISIG_SIGNER_3 -- **Purpose:** Owns the TimelockController, which holds DEFAULT_ADMIN_ROLE on production contracts -- **Solana equivalent:** Squads multisig (to be deployed on devnet) - ---- - -## GitHub Secrets Inventory - -All private keys are stored as GitHub Actions secrets in `HyperscapeAI/hyperbet`. Local copies were deleted after storage. - -| Secret Name | Corresponds To | Created | -|-------------|---------------|---------| -| `TESTNET_DEPLOYER_PRIVATE_KEY` | DEPLOYER EVM wallet | 2026-03-18 | -| `TESTNET_REPORTER_PRIVATE_KEY` | REPORTER EVM wallet | 2026-03-18 | -| `TESTNET_FINALIZER_PRIVATE_KEY` | FINALIZER EVM wallet | 2026-03-18 | -| `TESTNET_CHALLENGER_PRIVATE_KEY` | CHALLENGER EVM wallet | 2026-03-18 | -| `TESTNET_PAUSER_PRIVATE_KEY` | PAUSER EVM wallet | 2026-03-18 | -| `TESTNET_TREASURY_PRIVATE_KEY` | TREASURY EVM wallet | 2026-03-18 | -| `TESTNET_MARKET_MAKER_PRIVATE_KEY` | MARKET_MAKER EVM wallet | 2026-03-18 | -| `TESTNET_MULTISIG_SIGNER_1_PRIVATE_KEY` | MULTISIG_SIGNER_1 | 2026-03-18 | -| `TESTNET_MULTISIG_SIGNER_2_PRIVATE_KEY` | MULTISIG_SIGNER_2 | 2026-03-18 | -| `TESTNET_MULTISIG_SIGNER_3_PRIVATE_KEY` | MULTISIG_SIGNER_3 | 2026-03-18 | -| `TESTNET_SOLANA_DEPLOYER_KEYPAIR` | Solana DEPLOYER (JSON byte array) | 2026-03-18 | - -Additional repository-level secrets used by the Stage A workflows: - -| Secret Name | Purpose | -|-------------|---------| -| `ADMIN_ADDRESS` | Shared EVM admin role address | -| `MARKET_OPERATOR_ADDRESS` | Shared EVM market-operator role address | -| `REPORTER_ADDRESS` | Shared EVM reporter address when not derived from key | -| `TREASURY_ADDRESS` | Shared EVM treasury address when not derived from key | -| `MARKET_MAKER_ADDRESS` | Shared EVM market-maker address when not derived from key | -| `BSC_TESTNET_RPC` | BSC Testnet RPC URL | -| `AVAX_FUJI_RPC` | AVAX Fuji RPC URL | - -**Access pattern:** Secrets are consumed by GitHub Actions workflows only. They cannot be read back via the API (write-only). The `fund-multisig-signers.yml` workflow demonstrates the pattern for using deployer keys in CI. - -### Secret Consumption Model - -All Stage A deployment and verification runs through GitHub Actions workflows using **repository-level secrets**. There is no `staging` environment requirement for the current Stage A path. Operators do not need private keys on their machines. - -The workflows map repo secrets to the env surface consumed by `deploy-create2.ts`, `packages/hyperbet-solana/scripts/init-pm-config.ts`, and the verification scripts: - -| Runtime env | Secret source | -|-------------|--------------| -| `PRIVATE_KEY` | `TESTNET_DEPLOYER_PRIVATE_KEY` | -| `BSC_TESTNET_RPC` | `BSC_TESTNET_RPC` | -| `AVAX_FUJI_RPC` | `AVAX_FUJI_RPC` | -| `ADMIN_ADDRESS` | `ADMIN_ADDRESS` | -| `MARKET_OPERATOR_ADDRESS` | `MARKET_OPERATOR_ADDRESS` | -| `REPORTER_ADDRESS` | `REPORTER_ADDRESS` or `TESTNET_REPORTER_PRIVATE_KEY` | -| `FINALIZER_ADDRESS` | derived from `TESTNET_FINALIZER_PRIVATE_KEY` | -| `CHALLENGER_ADDRESS` | derived from `TESTNET_CHALLENGER_PRIVATE_KEY` | -| `PAUSER_ADDRESS` | derived from `TESTNET_PAUSER_PRIVATE_KEY` | -| `TREASURY_ADDRESS` | `TREASURY_ADDRESS` or `TESTNET_TREASURY_PRIVATE_KEY` | -| `MARKET_MAKER_ADDRESS` | `MARKET_MAKER_ADDRESS` or `TESTNET_MARKET_MAKER_PRIVATE_KEY` | -| `ANCHOR_WALLET` | temp runner file materialized from `TESTNET_SOLANA_DEPLOYER_KEYPAIR` | -| `SOLANA_EXPECTED_AUTHORITY` | derived from the temp `ANCHOR_WALLET` via `solana-keygen pubkey` | -| `SOLANA_EXPECTED_UPGRADE_AUTHORITY` | derived from the temp `ANCHOR_WALLET` via `solana-keygen pubkey` | - -The runtime export happens through [`scripts/export-stage-a-env.sh`](/Users/mac/Desktop/hyperbet/.claude/worktrees/blissful-golick/scripts/export-stage-a-env.sh), which validates that any stored public address matches its paired private key when both are present before exporting the effective Stage A env. - ---- +| DEPLOYER / AUTHORITY | `4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya` | Deploys programs and holds testnet authority | -## Funding Records +## GitHub Provisioning Audit (2026-03-25) -### Initial Deployer Funding (by user, via faucets) +### Repo-level secrets confirmed -| Chain | Wallet | Amount | Source | -|-------|--------|--------|--------| -| BSC Testnet | DEPLOYER | 0.3 tBNB | [BNB Chain Testnet Faucet](https://www.bnbchain.org/en/testnet-faucet) | -| AVAX Fuji | DEPLOYER | 1.5 AVAX | [Core Testnet Faucet](https://core.app/tools/testnet-faucet/) | -| Solana Devnet | DEPLOYER | 5.0 SOL | `solana airdrop 5 4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya --url devnet` | +- `BSC_TESTNET_RPC` +- `AVAX_FUJI_RPC` +- `TESTNET_DEPLOYER_PRIVATE_KEY` +- `TESTNET_REPORTER_PRIVATE_KEY` +- `TESTNET_FINALIZER_PRIVATE_KEY` +- `TESTNET_CHALLENGER_PRIVATE_KEY` +- `TESTNET_PAUSER_PRIVATE_KEY` +- `TESTNET_TREASURY_PRIVATE_KEY` +- `TESTNET_MARKET_MAKER_PRIVATE_KEY` +- `TESTNET_MARKET_OPERATOR_PRIVATE_KEY` +- `TESTNET_SOLANA_DEPLOYER_KEYPAIR` +- repo-level address helpers such as `ADMIN_ADDRESS`, `MARKET_OPERATOR_ADDRESS`, + `REPORTER_ADDRESS`, `TREASURY_ADDRESS`, and `MARKET_MAKER_ADDRESS` + +### Missing staged provisioning + +- no GitHub `staging` environment exists yet +- no staged environment secrets exist +- no staged environment vars exist +- no repo-level `HYPERBET_*_STAGING_*` vars or secrets exist + +### Missing shared token and address inputs for local Stage-A + +- `BSC_TESTNET_MUSD_TOKEN_ADDRESS` +- `AVAX_FUJI_MUSD_TOKEN_ADDRESS` +- `BSC_TESTNET_GOLD_TOKEN_ADDRESS` +- `AVAX_FUJI_GOLD_TOKEN_ADDRESS` +- optional perps margin token addresses when margin is not the GOLD token + +## What This Means Operationally -### Multisig Signer Funding (automated via CI workflow) +- local Stage-A deploy and verify is partially unblocked +- local BSC and AVAX AMM/perps rehearsal is still blocked on the missing shared + token/address inputs above +- GitHub staged proof is blocked until staged vars and secrets are provisioned +- GitHub staged soak is blocked until staged vars and secrets are provisioned -Funded by `.github/workflows/fund-multisig-signers.yml` using `TESTNET_DEPLOYER_PRIVATE_KEY` from GitHub Secrets. Workflow run: [#23249394641](https://github.com/HyperscapeAI/hyperbet/actions/runs/23249394641). +The current local Stage-A export path is: -| Chain | Wallet | Amount | Tx Hash | -|-------|--------|--------|---------| -| BSC Testnet | MULTISIG_SIGNER_1 | 0.010 tBNB | `0x25102368c7d8af26d4929b02df00fb50b09c884ab417e31bdbdf0e399916e673` (first run) + second run | -| BSC Testnet | MULTISIG_SIGNER_2 | 0.005 tBNB | Via workflow run #23249394641 | -| BSC Testnet | MULTISIG_SIGNER_3 | 0.005 tBNB | Via workflow run #23249394641 | -| AVAX Fuji | MULTISIG_SIGNER_1 | 0.020 AVAX | Via workflow run #23249394641 | -| AVAX Fuji | MULTISIG_SIGNER_2 | 0.020 AVAX | Via workflow run #23249394641 | -| AVAX Fuji | MULTISIG_SIGNER_3 | 0.020 AVAX | Via workflow run #23249394641 | +- [scripts/export-stage-a-env.sh](../../scripts/export-stage-a-env.sh) -### Balance Snapshot (post-funding, 2026-03-18) +The current local-first runner is: -| Wallet | BSC Testnet | AVAX Fuji | Solana Devnet | -|--------|-----------|----------|--------------| -| DEPLOYER | 0.280 tBNB | 1.440 AVAX | 5.000 SOL | -| MULTISIG_SIGNER_1 | 0.010 tBNB | 0.020 AVAX | — | -| MULTISIG_SIGNER_2 | 0.005 tBNB | 0.020 AVAX | — | -| MULTISIG_SIGNER_3 | 0.005 tBNB | 0.020 AVAX | — | +- [scripts/run-local-stage-a.ts](../../scripts/run-local-stage-a.ts) ---- +## Current Workflow Model -## Key Generation Method +### Local-first bring-up -- **EVM wallets:** Generated via `cast wallet new` (Foundry). Each role got a fresh keypair. Private keys were immediately stored in GitHub Secrets and deleted from local filesystem. -- **Solana wallet:** Generated via `solana-keygen new`. Keypair JSON stored in GitHub Secrets as `TESTNET_SOLANA_DEPLOYER_KEYPAIR`. -- **No key reuse** across roles. Each role has a unique keypair. -- **No mainnet keys** were generated. These are testnet-only. Mainnet keys will be generated fresh during the Stage B ceremony. +Use local execution first: ---- +```bash +bun run stagea:local +``` -## CI Workflows Using Secrets +### GitHub artifactized confirmation + +After local Stage-A is green and staging is provisioned, use: + +- `.github/workflows/deploy-testnet-v3.yml` +- `.github/workflows/verify-testnet-deployment.yml` +- `.github/workflows/staged-live-proof.yml` +- `.github/workflows/pm-soak.yml` + +These workflows now target the phase-1 full-product non-mainnet lane, not a +PM-only scope. + +## Funding Records + +### Initial deployer funding + +| Chain | Wallet | Amount | Source | +|-------|--------|--------|--------| +| BSC testnet | DEPLOYER | 0.3 tBNB | [BNB Chain Testnet Faucet](https://www.bnbchain.org/en/testnet-faucet) | +| AVAX Fuji | DEPLOYER | 1.5 AVAX | [Core Testnet Faucet](https://core.app/tools/testnet-faucet/) | +| Solana devnet | DEPLOYER | 5.0 SOL | `solana airdrop 5 4zVqVfrY5AjqKytAEBEo3MHk2PQBj6u7bTvUcWAu9Sya --url devnet` | -| Workflow | Secrets Used | Purpose | -|----------|-------------|---------| -| `fund-multisig-signers.yml` | `TESTNET_DEPLOYER_PRIVATE_KEY` | Sends gas to multisig signers | -| `deploy-testnet-v3.yml` | repo-level Stage A secrets listed above | While branch-only, a push to `enoomian/pm16-17-20-21` triggers PM-scope deploy + verify for BSC Testnet, AVAX Fuji, and Solana devnet | -| `verify-testnet-deployment.yml` | repo-level Stage A secrets listed above | Verifies deployed testnet PM surfaces and writes structured artifacts | +### Multisig signer funding ---- +Funded by `.github/workflows/fund-multisig-signers.yml` using +`TESTNET_DEPLOYER_PRIVATE_KEY`. Workflow run: +[#23249394641](https://github.com/HyperscapeAI/hyperbet/actions/runs/23249394641). ## Security Notes -1. **All keys are testnet-only.** They hold zero real value. Compromise has no financial impact. -2. **GitHub Secrets are write-only.** Once stored, they cannot be read back via API or CLI. They are only injected into workflow runtime. -3. **Local copies were deleted** immediately after GitHub Secret storage was confirmed. The only exception in Stage A is the temporary runner-local `ANCHOR_WALLET` file materialized from `TESTNET_SOLANA_DEPLOYER_KEYPAIR` during workflow execution. -4. **Mainnet keys will be generated separately** during the Stage B ceremony, following the same role separation but with hardware wallet / multisig custody. -5. **The funding workflow** (`fund-multisig-signers.yml`) is triggered by push to `enoomian/pm16-17-20-21` when its own file changes. It should be removed or disabled before merge to `develop`. -6. **The Stage A PM deploy workflow** (`deploy-testnet-v3.yml`) is also push-triggered on `enoomian/pm16-17-20-21` while it remains off the default branch, because branch-only `workflow_dispatch` cannot be invoked from GitHub CLI/API. - ---- - -## Chain Configuration Summary - -| Parameter | BSC Testnet | AVAX Fuji | Solana Devnet | -|-----------|-----------|----------|--------------| -| Chain ID | 97 | 43113 | devnet | -| RPC | `https://data-seed-prebsc-1-s1.bnbchain.org:8545` | Alchemy (via env) | `https://api.devnet.solana.com` | -| Block Explorer | `https://testnet.bscscan.com` | `https://testnet.snowtrace.io` | `https://explorer.solana.com/?cluster=devnet` | -| Gas Token | tBNB | AVAX | SOL | -| CREATE2 Proxy | Arachnid (`0x4e59b44847b379578588920ca78fbf26c0b4956c`) | Same | N/A | -| Salt Policy | `keccak256("hyperbet/v3/{ContractName}")` | Same | N/A | +1. All listed keys are testnet-only. +2. GitHub secrets are write-only and cannot be read back through the API. +3. Historically committed deploy keys are still treated as burned for + production. Mainnet keys must be generated and handled separately. +4. Stage-A and staged proof should never be treated as a substitute for + canonical mainnet registry truth. diff --git a/docs/release/tracking-document-map.md b/docs/release/tracking-document-map.md new file mode 100644 index 00000000..f73cdf39 --- /dev/null +++ b/docs/release/tracking-document-map.md @@ -0,0 +1,94 @@ +# Hyperbet Tracking Document Map + +> **TL;DR:** This map defines which documents own live launch truth, which documents own open work, which documents only preserve evidence, and which documents are historical snapshots. Hyperbet's full launch target remains `PM + perps + internal AMM`, the active production-readiness gate is `BSC + Solana`, and `AVAX` is preserved as a parked follow-on epic. The implementation target is runtime-family based: shared `EVM` plus `SVM`, not one named EVM chain. + +## Scope Contract + +Use this scope statement consistently across every active tracking document: + +- full product destination: `PM + perps + internal AMM` +- active production-readiness gate: `BSC + Solana` +- parked follow-on epic: `AVAX` +- `AMM` is internal infrastructure, not a required retail browser surface +- implementation target: shared `EVM` runtime plus `SVM` + +## Chain Generality Contract + +Use this interpretation consistently across active documents and issue creation: + +- `BSC` is the current active EVM proving and launch wrapper, not the exclusive + long-term implementation target +- unless a ticket explicitly says `wrapper-specific`, `deployment-specific`, or + `governance/evidence-only`, EVM work should land in shared EVM modules, + registry-driven config, and reusable app/runtime paths +- `Solana` owns the active `SVM` lane +- future EVM onboardings such as `Base` or reactivated `AVAX` should consume + the shared EVM path rather than require BSC-only rewrites + +## Document Role Contract + +| Role | Purpose | May own open work? | +|---|---|---| +| `Canonical guidance` | Says what is true now, what the active gate is, and what the top blockers are. | Summary only | +| `Canonical backlog` | Owns all remaining implementation, hardening, CI, launch, and ops work. | Yes | +| `Canonical evidence/status` | Proves what already passed and records current runtime status. | No | +| `Runbook` | Explains how to operate, deploy, verify, or recover a system. | No | +| `Historical snapshot` | Preserves prior branch-era execution context and evidence. | No | + +## Canonical Guidance + +| Document | Role | Current job | +|---|---|---| +| [production-readiness-audit-2026-03-29.md](production-readiness-audit-2026-03-29.md) | Canonical guidance | Production-readiness verdict, blocker inventory, execution order | +| [github-project-production-backlog.md](github-project-production-backlog.md) | Canonical backlog | Single issue-seed source for all remaining work | +| [../prediction-market-release-prep.md](../prediction-market-release-prep.md) | Canonical guidance | Reviewer-facing release summary and blocker summary | +| [prediction-market-launch-freeze-tracker.md](prediction-market-launch-freeze-tracker.md) | Canonical guidance | Current freeze posture and blocker summary | +| [launch-ops-evidence-index.md](launch-ops-evidence-index.md) | Canonical guidance | Links launch evidence to repo artifacts and names missing evidence | +| [../hyperbet-production-deploy.md](../hyperbet-production-deploy.md) | Canonical guidance | Production and staging topology, required env contract | +| [external-audit-package-checklist.md](external-audit-package-checklist.md) | Canonical guidance | Audit-handoff packet completeness only | + +## Canonical Evidence And Status + +| Document | Role | Current job | +|---|---|---| +| [runtime-integration-readiness-matrix.md](runtime-integration-readiness-matrix.md) | Canonical evidence/status | End-to-end runtime loop status for `BSC + Solana` | +| [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md) | Canonical evidence/status | Browser-to-chain and real-Hyperscapes acceptance evidence | +| [stage-a-promotion-execution-ledger.md](stage-a-promotion-execution-ledger.md) | Canonical evidence/status | Detailed Stage-A execution log, tx hashes, balances, artifacts | +| [launch-ops-evidence-index.md](launch-ops-evidence-index.md) | Canonical guidance + evidence index | Reviewer entrypoint to evidence references | + +## Runbooks + +| Document | Role | Current job | +|---|---|---| +| [../runbooks/hyperscapes-local-pm-integration.md](../runbooks/hyperscapes-local-pm-integration.md) | Runbook | Local real-duel integration steps and validation | +| [../runbooks/prediction-market-test-flow.md](../runbooks/prediction-market-test-flow.md) | Runbook | PM flow expectations and test guidance | +| [../runbooks/create2-mainnet-deploy.md](../runbooks/create2-mainnet-deploy.md) | Runbook | CREATE2 deployment procedure for active launch scope | +| [../runbooks/README.md](../runbooks/README.md) | Runbook | Index for operational runbooks | + +## Historical Snapshots + +These documents remain important, but they no longer own current blockers or open work: + +| Document | Why it stays | +|---|---| +| [pm-launch-execution-plan.md](pm-launch-execution-plan.md) | Preserved superset strategy and checklist history | +| [testnet-operations-ledger.md](testnet-operations-ledger.md) | Historical Stage-A wallet and provisioning record | +| [localnet-headless-validation-tracker.md](localnet-headless-validation-tracker.md) | Branch-era local runner findings | +| [stage-a-browser-acceptance-matrix.md](stage-a-browser-acceptance-matrix.md) | Execution evidence, not backlog ownership | +| [stage-a-promotion-execution-ledger.md](stage-a-promotion-execution-ledger.md) | Append-only evidence log | +| [../enoomian-prediction-market-sprint.md](../enoomian-prediction-market-sprint.md) | Closed sprint tracker | +| [../enoomian-evm-standardization-decisions.md](../enoomian-evm-standardization-decisions.md) | Historical design log | +| [../enoomian-next-phase-gates.md](../enoomian-next-phase-gates.md) | Historical planning artifact | + +## Usage Rules + +1. If the question is “what is still left?”, use the backlog first. +2. If the question is “is the system actually running end to end?”, use the runtime matrix first. +3. If the question is “what has already been proven?”, use the evidence docs. +4. If the question is “how do I operate this?”, use runbooks. +5. If a historical document disagrees with a canonical document, the canonical document wins unless the historical document is being cited as evidence. +6. The GitHub Project execution surface is one full Kanban board derived from + the canonical backlog; do not create dated sprint contracts or calendar + promises in tracking docs. +7. App shell, account, rewards, SDK, runtime, protocol, and off-repo launch + work all belong on the same execution surface unless explicitly parked. diff --git a/docs/runbooks/create2-mainnet-deploy.md b/docs/runbooks/create2-mainnet-deploy.md index 4b340b3f..7741cb4f 100644 --- a/docs/runbooks/create2-mainnet-deploy.md +++ b/docs/runbooks/create2-mainnet-deploy.md @@ -1,145 +1,124 @@ -# CREATE2 Deterministic Deployment — Mainnet Runbook +# CREATE2 Deterministic Deployment Runbook -> **Version**: v3 — CREATE2 redeployment -> **Factory**: Arachnid Deterministic Deployment Proxy (`0x4e59b44847b379578588920cA78FbF26c0B4956C`) +> **TL;DR:** This runbook covers the EVM portion of the phase-1 launch train. The active production-readiness EVM chain is `BSC`. `AVAX` remains a preserved but isolated follow-on lane, and `Base` remains an optional add-chain rehearsal lane. PM CREATE2 deployment must be followed by AMM and perps deployment plus full-product verification before any registry values are promoted. -## Pre-flight (all chains) +> **Factory:** Arachnid Deterministic Deployment Proxy (`0x4e59b44847b379578588920cA78FbF26c0B4956C`) + +## Preflight + +Current scope note: + +- blocking EVM path: `BSC` +- preserved but isolated follow-on lane: `AVAX` +- when this runbook mentions AVAX below, treat it as preserved operational + guidance, not a blocker for the current signoff decision + +1. Compute deterministic PM addresses: -### 1. Compute deterministic addresses ```bash npx hardhat run scripts/predict-create2-addresses.ts ``` -Record both predicted addresses — they will be identical on every chain. -### 2. Verify Arachnid proxy exists on target chain +2. Verify the deterministic deployment proxy exists on the target chain: + ```bash -cast code 0x4e59b44847b379578588920cA78FbF26c0B4956C --rpc-url $RPC_URL -# Must return non-empty bytecode +cast code 0x4e59b44847b379578588920cA78FbF26c0B4956C --rpc-url "$RPC_URL" ``` -### 3. Fund deployer wallet -- Same deployer EOA on all chains -- Required: ~0.1 native token per chain for gas +3. Confirm governance and operator addresses are finalized for the target + environment: -### 4. Prepare governance wallet addresses -All addresses **must be identical** across chains for CREATE2 address parity: +- `ADMIN_ADDRESS` +- `REPORTER_ADDRESS` +- `FINALIZER_ADDRESS` +- `CHALLENGER_ADDRESS` +- `PAUSER_ADDRESS` +- `MARKET_OPERATOR_ADDRESS` +- `TREASURY_ADDRESS` +- `MARKET_MAKER_ADDRESS` -| Role | Env Var | -|---|---| -| Admin/Timelock | `ADMIN_ADDRESS` | -| Reporter | `REPORTER_ADDRESS` | -| Finalizer | `FINALIZER_ADDRESS` | -| Challenger | `CHALLENGER_ADDRESS` | -| Pauser | `PAUSER_ADDRESS` | -| Market Operator | `MARKET_OPERATOR_ADDRESS` | -| Treasury | `TREASURY_ADDRESS` | -| Market Maker | `MARKET_MAKER_ADDRESS` | -| Dispute Window | `DISPUTE_WINDOW_SECONDS` (default: 3600) | +4. Confirm shared token inputs for AMM and perps exist for the target + environment: ---- +- `MUSD_TOKEN_ADDRESS` +- `GOLD_TOKEN_ADDRESS` +- perps margin token address when distinct from `GOLD_TOKEN_ADDRESS` -## Rehearsal: Testnet Deploy +## Testnet Rehearsal -### 5. Deploy to AVAX Fuji -```bash -ADMIN_ADDRESS=0x... REPORTER_ADDRESS=0x... FINALIZER_ADDRESS=0x... \ -CHALLENGER_ADDRESS=0x... MARKET_OPERATOR_ADDRESS=0x... \ -TREASURY_ADDRESS=0x... MARKET_MAKER_ADDRESS=0x... \ -npx hardhat run scripts/deploy-create2.ts --network avaxFuji -``` +### BSC testnet -### 6. Deploy to BSC Testnet ```bash -# Same governance vars as step 5 npx hardhat run scripts/deploy-create2.ts --network bscTestnet +npx hardhat run scripts/deploy-amm.ts --network bscTestnet +npx hardhat run scripts/deploy-perps.ts --network bscTestnet +node --import tsx scripts/verify-deployment.ts --network bscTestnet ``` -### 7. Deploy to Base Sepolia -```bash -npx hardhat run scripts/deploy-create2.ts --network baseSepolia -``` +### AVAX Fuji -### 8. Verify address parity across testnets ```bash -diff <(jq .duelOracleAddress deployments/avaxFuji.json) \ - <(jq .duelOracleAddress deployments/bscTestnet.json) -diff <(jq .duelOracleAddress deployments/avaxFuji.json) \ - <(jq .duelOracleAddress deployments/baseSepolia.json) -# Both diffs must be empty +npx hardhat run scripts/deploy-create2.ts --network avaxFuji +npx hardhat run scripts/deploy-amm.ts --network avaxFuji +npx hardhat run scripts/deploy-perps.ts --network avaxFuji +node --import tsx scripts/verify-deployment.ts --network avaxFuji ``` -### 9. Run bootstrap smoke on each testnet +### Optional Base add-chain rehearsal + ```bash -# Verify full lifecycle: upsert → bet → propose → finalize → claim -node packages/hyperbet-avax/keeper/avax-fuji-bootstrap.mjs --scenario unmatched-gtc +npx hardhat run scripts/deploy-create2.ts --network baseSepolia +npx hardhat run scripts/deploy-amm.ts --network baseSepolia +npx hardhat run scripts/deploy-perps.ts --network baseSepolia +node --import tsx scripts/verify-deployment.ts --network baseSepolia ``` -### 10. Signoff checkpoint -- [ ] All testnet addresses match -- [ ] Bootstrap smoke passes on all testnets -- [ ] Parity tests pass in CI -- [ ] Governance roles confirmed on-chain via block explorer +## Promotion Rules + +- Do not promote testnet receipts into mainnet registry truth. +- Do not treat Base success as a phase-1 blocker or phase-1 completion signal. +- Do not treat PM CREATE2 success alone as full-product completion. AMM, + perps, and full-product verification must also pass. ---- +## Mainnet Sequence -## Mainnet Deploy +### BSC mainnet -### 11. Deploy to BSC Mainnet ```bash npx hardhat run scripts/deploy-create2.ts --network bsc +npx hardhat run scripts/deploy-amm.ts --network bsc +npx hardhat run scripts/deploy-perps.ts --network bsc +node --import tsx scripts/verify-deployment.ts --network bsc ``` -### 12. Deploy to Base Mainnet -```bash -npx hardhat run scripts/deploy-create2.ts --network base -``` +### AVAX mainnet -### 13. Deploy to AVAX Mainnet ```bash npx hardhat run scripts/deploy-create2.ts --network avax +npx hardhat run scripts/deploy-amm.ts --network avax +npx hardhat run scripts/deploy-perps.ts --network avax +node --import tsx scripts/verify-deployment.ts --network avax ``` -### 14. Verify mainnet address parity -Same diff checks as testnet step 8. - -### 15. Update chain registry -Verify the deployment receipts match and update `hyperbet-chain-registry` with v3 addresses. - -### 16. Block explorer verification -For each chain, verify: -- [ ] Contract source matches committed Solidity -- [ ] Constructor args decode correctly -- [ ] Roles assigned as expected -- [ ] `duelOracle` on GoldClob points to correct oracle +### Optional Base mainnet after phase-1 -### 17. Staged live proof ```bash -gh workflow run staged-live-proof.yml -f target=all -f mode=read-only -# After review: -gh workflow run staged-live-proof.yml -f target=all -f mode=canary-write +npx hardhat run scripts/deploy-create2.ts --network base +npx hardhat run scripts/deploy-amm.ts --network base +npx hardhat run scripts/deploy-perps.ts --network base +node --import tsx scripts/verify-deployment.ts --network base ``` ---- - -## Post-deploy - -### 18. Archive old addresses -Update `docs/release/deprecated-v2-addresses.md` with deprecation date. - -### 19. Update downstream consumers -- Chain registry ✅ (step 15) -- Keeper configs -- UI contract references -- Block explorer links in docs - ---- - -## Adding a New Chain (Post-Refactor) +## Registry And Evidence -After the data-driven refactor, extending to a new EVM chain requires: +After final mainnet verification succeeds: -1. Add network block to `hardhat.config.ts` (6 lines) -2. Add deployment entry to chain registry `EVM_DEPLOYMENTS` (25 lines) -3. Run `npx hardhat run scripts/deploy-create2.ts --network newChain` +1. Update `packages/hyperbet-chain-registry/src/index.ts` from final receipts + only. +2. Archive deployment receipts and verify outputs. +3. Capture staged proof and soak evidence against the promoted environment. +4. Record governance transfer and freeze transaction hashes. -**Addresses will be identical** — CREATE2 guarantees same address from same args. +For the current production-readiness path, only the BSC outputs above are +blocking. AVAX outputs remain preserved follow-on artifacts unless the lane is +explicitly reactivated. diff --git a/docs/runbooks/hyperscapes-local-pm-integration.md b/docs/runbooks/hyperscapes-local-pm-integration.md index 7de392ff..a830fd62 100644 --- a/docs/runbooks/hyperscapes-local-pm-integration.md +++ b/docs/runbooks/hyperscapes-local-pm-integration.md @@ -1,5 +1,7 @@ # Hyperscapes Local PM Integration +> **TL;DR:** This is the fastest local debug lane for the real `Hyperscapes -> Hyperbet` integration. It uses local Hyperscapes plus local keeper and UI, and it can optionally drive local BSC and AVAX write paths with anvil-backed deployments. For the EVM keeper path, the canonical upstream contract is the versioned authenticated bet-sync feed under `/api/internal/bet-sync/*`; the older `/api/streaming/state` route remains a compatibility and inspection surface. It is not the final signoff lane; release signoff still comes from staged proof and soak. + This is the local integration path for prediction markets against the real Hyperscapes duel stack. It does **not** seed synthetic markets and it does **not** treat the game server as the Hyperbet API. @@ -13,11 +15,15 @@ requirements. ## Architecture 1. Hyperscapes is the duel event source. - - local game/server stack serves `GET /api/streaming/state` + - local game/server stack serves: + - `GET /api/internal/bet-sync/state` + - `GET /api/internal/bet-sync/events` + - `GET /api/streaming/state` for compatibility and inspection - duel lifecycle comes from the running game 2. Hyperbet keeper is the bridge layer. - - polls the Hyperscapes streaming endpoint + - for the local EVM runner, consumes the versioned bet-sync feed + - older keepers may still poll the legacy streaming endpoint - optionally runs the keeper bot internally - exposes: - `/status` @@ -30,7 +36,13 @@ requirements. - `VITE_GAME_WS_URL=ws://127.0.0.1:5555/ws` This split is required because the Hyperscapes server provides duel telemetry, -while the keeper service provides prediction-market state. +while the keeper service provides prediction-market state and chain-specific +market views. + +For the richer feed path, Hyperscapes protects `/api/internal/bet-sync/*` with +`BETTING_FEED_ACCESS_TOKEN` unless an explicit local development bypass is +enabled. Treat that token as part of the keeper-to-game contract, not as a UI +credential. For local write-path smoke, the runner can boot fresh anvil-backed BSC and AVAX deployments before starting the keeper. That path seeds fresh local EVM @@ -106,9 +118,9 @@ There are two separate wallet classes: - used by the UI for order placement and claims - private keys stay local under `keys/local-smoke/` - public addresses are tracked in - [local-smoke-wallets.json](/Users/mac/Desktop/hyperbet/.claude/worktrees/blissful-golick/docs/release/evidence/local-smoke-wallets.json) + [local-smoke-wallets.json](../release/evidence/local-smoke-wallets.json) - GitHub can fund them through - [fund-local-smoke-wallets.yml](/Users/mac/Desktop/hyperbet/.claude/worktrees/blissful-golick/.github/workflows/fund-local-smoke-wallets.yml) + [fund-local-smoke-wallets.yml](../../.github/workflows/fund-local-smoke-wallets.yml) 2. Keeper writer wallets - required for deployed testnet market automation @@ -127,6 +139,17 @@ From the Hyperbet repo root: bash scripts/run-hyperscapes-pm-local.sh ``` +Repo location discovery: + +- the runner first honors `HYPERSCAPES_ROOT` if you set it explicitly +- otherwise it auto-detects common sibling locations such as: + - `/.worktrees/hyperscapes-main-latest-e2e` + - `/hyperscapes-main-latest-e2e` + - `/.worktrees/hyperscapes-stream-bet-sync` + - `/hyperscapes-stream-bet-sync` +- if your Hyperscapes checkout was moved elsewhere, set + `HYPERSCAPES_ROOT=/abs/path/to/hyperscapes` + Defaults: - Hyperscapes game/server: `http://127.0.0.1:5555` @@ -135,6 +158,9 @@ Defaults: - EVM keeper chain scope: `bsc,avax` - Hyperscapes chain bootstrap: skipped - Hyperscapes node env: `development` +- interactive UI opening: disabled by default +- local monitor browser mode: headless by default +- screenshot viewport: `1280x720` The script: @@ -144,14 +170,41 @@ The script: `packages/hyperbet-bsc/app/tests/e2e/` and `packages/hyperbet-avax/app/tests/e2e/` 3. starts the local Hyperbet EVM keeper service against - `http://127.0.0.1:5555/api/streaming/state` + `http://127.0.0.1:5555/api/internal/bet-sync/state` and + `http://127.0.0.1:5555/api/internal/bet-sync/events`, with + `http://127.0.0.1:5555/api/streaming/state` retained as the compatibility + stream source 4. starts the local Hyperbet EVM app pointed at the keeper service -5. opens both local UIs by default: - - Hyperscapes stream UI: `http://127.0.0.1:3333/stream.html` - - Hyperbet EVM UI: `http://127.0.0.1:4179/?debug` -6. starts the PM soak follow monitor in the background, which records JSON +5. keeps UI opening off by default; manual browser launch is opt-in through + `OPEN_LOCAL_UI=true` +6. can start the PM soak follow monitor in the background, which records JSON state plus paired UI screenshots into: - `output/playwright/pm-soak//` + - screenshots are captured headlessly at `1280x720` unless overridden + +The default Hyperbet local page is now the normal betting surface: + +- `http://127.0.0.1:4179/` + +The `?debug=1` query is optional and only enables hidden E2E/operator controls. +It is not required for the real local betting flow or for headless stream +validation. + +Monitor and harness controls are explicit: +- `PM_E2E_MONITOR=true|false` toggles `scripts/pm-soak-monitor.ts` +- `PM_E2E_FULL_SOAK=true|false` toggles `scripts/soak-harness.ts` +- `PW_HEADLESS=1` keeps Playwright headless +- `PW_BROWSER_CHANNEL=chrome` is the preferred macOS local setting +- `PW_WEBGPU_ARGS="--enable-unsafe-webgpu"` keeps the local stream renderer on + the headless WebGPU lane +- `PM_SOAK_SCREENSHOT_WIDTH=1280` +- `PM_SOAK_SCREENSHOT_HEIGHT=720` + +`PM_E2E_MONITOR` defaults to the value of `CAPTURE_LOCAL_UI_FLOW`. + +When you enable `PM_E2E_FULL_SOAK=true`, the runner also starts +`scripts/soak-harness.ts` so the same local session executes an additional +PM-AMM + perps path against local BSC and active stream cycles. The PM soak monitor records key incidences automatically: @@ -166,10 +219,10 @@ The PM soak monitor records key incidences automatically: The runner auto-loads these gitignored local env files when present: -- `/Users/mac/Desktop/hyperbet/.claude/worktrees/blissful-golick/.env.stage-a.testnet.local` -- `/Users/mac/Desktop/hyperbet/.claude/worktrees/blissful-golick/.env.testnet.local` -- `/Users/mac/Desktop/hyperbet/.claude/worktrees/blissful-golick/packages/hyperbet-evm/keeper/.env` -- `/Users/mac/Desktop/hyperbet/.claude/worktrees/blissful-golick/packages/hyperbet-evm/app/.env.local` +- `/.env.stage-a.testnet.local` +- `/.env.testnet.local` +- `/packages/hyperbet-evm/keeper/.env` +- `/packages/hyperbet-evm/app/.env.local` Relevant writer env names: @@ -199,6 +252,19 @@ HYPERSCAPES_SKIP_CHAIN_SETUP=false bash scripts/run-hyperscapes-pm-local.sh HYPERSCAPES_DUEL_NODE_ENV=production JWT_SECRET=... bash scripts/run-hyperscapes-pm-local.sh OPEN_LOCAL_UI=false bash scripts/run-hyperscapes-pm-local.sh CAPTURE_LOCAL_UI_FLOW=false bash scripts/run-hyperscapes-pm-local.sh +HYPERBET_UI_DEBUG=true bash scripts/run-hyperscapes-pm-local.sh +PM_E2E_MONITOR=true \ +PM_E2E_FULL_SOAK=true \ +PM_SOAK_LOCAL_DURATION_MIN=25 \ +PM_E2E_HARNESS_DURATION_MIN=25 \ +bash scripts/run-hyperscapes-pm-local.sh + +# Full E2E without local monitor/UI capture: +OPEN_LOCAL_UI=false \ +PM_E2E_MONITOR=false \ +PM_E2E_FULL_SOAK=true \ +PM_E2E_HARNESS_DURATION_MIN=25 \ +bash scripts/run-hyperscapes-pm-local.sh ``` ## Acceptance @@ -206,21 +272,23 @@ CAPTURE_LOCAL_UI_FLOW=false bash scripts/run-hyperscapes-pm-local.sh Minimum healthy local integrated state: 1. `GET http://127.0.0.1:5555/api/streaming/state` returns live duel state. -2. `GET http://127.0.0.1:8080/status` returns keeper health. -3. `GET http://127.0.0.1:8080/api/arena/prediction-markets/active` returns +2. `GET http://127.0.0.1:5555/api/internal/bet-sync/state` returns a versioned + betting-feed payload when the local token contract is satisfied. +3. `GET http://127.0.0.1:8080/status` returns keeper health. +4. `GET http://127.0.0.1:8080/api/arena/prediction-markets/active` returns prediction-market state. -4. `http://127.0.0.1:4179` loads with duel telemetry from Hyperscapes and +5. `http://127.0.0.1:4179` loads with duel telemetry from Hyperscapes and prediction markets from the keeper service. -5. With writer authority present locally, a live duel should drive: +6. With writer authority present locally, a live duel should drive: - market open - market lock - oracle proposal/finalization - claimable resolved state -6. If local writer authority is intentionally absent, the truthful expected +7. If local writer authority is intentionally absent, the truthful expected state is: - live duel state visible in keeper and UI - empty `markets[]` on `/api/arena/prediction-markets/active` - `ENABLE_KEEPER_BOT=false` -7. The local evidence bundle contains paired Hyperscapes and Hyperbet UI +8. The local evidence bundle contains paired Hyperscapes and Hyperbet UI screenshots plus the backing keeper/game JSON for each captured incidence. From c2b94ec0cee3e1d31392ee1cdcceb859d0b997e8 Mon Sep 17 00:00:00 2001 From: rndrntwrk <180591682+rndrntwrk@users.noreply.github.com> Date: Wed, 1 Apr 2026 04:30:54 -0500 Subject: [PATCH 003/161] fix: route CI builds through chain-specific keeper variables Add per-chain keeper URL variables to the CI matrix so each package build resolves VITE_GAME_API_URL from its own GitHub variable (HYPERBET_SOLANA_KEEPER_URL, HYPERBET_BSC_KEEPER_URL, etc.) with public-facing domains preserved as last-resort fallbacks. Restore public domains in client config.ts defaults replacing Railway infrastructure URLs that were previously hardcoded. --- .github/workflows/ci.yml | 20 +++++++++++++++---- bun.lock | 2 -- packages/hyperbet-bsc/app/src/lib/config.ts | 2 +- .../hyperbet-solana/app/src/lib/config.ts | 2 +- packages/hyperbet-ui/src/lib/config.ts | 2 +- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d589c259..8598d58d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -226,6 +226,10 @@ jobs: audit_target: pages:solana build_mode: mainnet-beta solana_cluster: mainnet-beta + keeper_url_var: HYPERBET_SOLANA_KEEPER_URL + keeper_ws_url_var: HYPERBET_SOLANA_KEEPER_WS_URL + keeper_fallback_url: "https://api.hyperbet.win" + keeper_fallback_ws_url: "wss://api.hyperbet.win/ws" avax_chain_id: "" avax_gold_clob_address: "" - package: hyperbet-bsc @@ -233,6 +237,10 @@ jobs: audit_target: pages:bsc build_mode: mainnet-beta solana_cluster: mainnet-beta + keeper_url_var: HYPERBET_BSC_KEEPER_URL + keeper_ws_url_var: HYPERBET_BSC_KEEPER_WS_URL + keeper_fallback_url: "https://bsc-api.hyperbet.win" + keeper_fallback_ws_url: "wss://bsc-api.hyperbet.win/ws" avax_chain_id: "" avax_gold_clob_address: "" - package: hyperbet-avax @@ -240,12 +248,16 @@ jobs: audit_target: app:avax build_mode: testnet solana_cluster: testnet + keeper_url_var: HYPERBET_AVAX_KEEPER_URL + keeper_ws_url_var: HYPERBET_AVAX_KEEPER_WS_URL + keeper_fallback_url: "https://avax-api.hyperbet.win" + keeper_fallback_ws_url: "wss://avax-api.hyperbet.win/ws" avax_chain_id: "" avax_gold_clob_address: "" env: CF_PAGES_COMMIT_SHA: ${{ github.sha }} - VITE_GAME_API_URL: https://api.hyperbet.win - VITE_GAME_WS_URL: wss://api.hyperbet.win/ws + VITE_GAME_API_URL: ${{ vars[matrix.keeper_url_var] || matrix.keeper_fallback_url }} + VITE_GAME_WS_URL: ${{ vars[matrix.keeper_ws_url_var] || matrix.keeper_fallback_ws_url }} VITE_SOLANA_CLUSTER: ${{ matrix.solana_cluster }} VITE_USE_GAME_RPC_PROXY: "true" VITE_USE_GAME_EVM_RPC_PROXY: "true" @@ -335,8 +347,8 @@ jobs: needs: shared-validation env: CF_PAGES_COMMIT_SHA: ${{ github.sha }} - VITE_GAME_API_URL: https://api.hyperbet.win - VITE_GAME_WS_URL: wss://api.hyperbet.win/ws + VITE_GAME_API_URL: ${{ vars.HYPERBET_BSC_KEEPER_URL || 'https://bsc-api.hyperbet.win' }} + VITE_GAME_WS_URL: ${{ vars.HYPERBET_BSC_KEEPER_WS_URL || 'wss://bsc-api.hyperbet.win/ws' }} VITE_SOLANA_CLUSTER: mainnet-beta VITE_USE_GAME_RPC_PROXY: "true" VITE_USE_GAME_EVM_RPC_PROXY: "true" diff --git a/bun.lock b/bun.lock index 3ae54ba6..a2c9d030 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "@hyperbet/monorepo", @@ -306,7 +305,6 @@ "@solana/web3.js": "^1.95.4", "bn.js": "^5.2.1", "bs58": "^6.0.0", - "clsx": "^2.1.1", "ethers": "^6.13.4", }, "devDependencies": { diff --git a/packages/hyperbet-bsc/app/src/lib/config.ts b/packages/hyperbet-bsc/app/src/lib/config.ts index e76edcdc..79f9b032 100644 --- a/packages/hyperbet-bsc/app/src/lib/config.ts +++ b/packages/hyperbet-bsc/app/src/lib/config.ts @@ -276,7 +276,7 @@ interface EnvConfig { const DEFAULT_STREAM_URL = "https://www.twitch.tv/hyperscapeai"; const DEFAULT_STREAM_FALLBACK_URL = ""; const DEFAULT_GAME_API_URL = "http://127.0.0.1:5555"; -const DEFAULT_PRODUCTION_GAME_API_URL = "https://gold-betting-keeper-production.up.railway.app"; +const DEFAULT_PRODUCTION_GAME_API_URL = "https://bsc-api.hyperbet.win"; const baseConfig: Partial = { betWindowSeconds: 300, diff --git a/packages/hyperbet-solana/app/src/lib/config.ts b/packages/hyperbet-solana/app/src/lib/config.ts index 834b6fbe..2401ebfc 100644 --- a/packages/hyperbet-solana/app/src/lib/config.ts +++ b/packages/hyperbet-solana/app/src/lib/config.ts @@ -182,7 +182,7 @@ export interface EnvConfig { const DEFAULT_STREAM_URL = "https://www.twitch.tv/hyperscapeai"; const DEFAULT_STREAM_FALLBACK_URL = ""; const DEFAULT_GAME_API_URL = "http://127.0.0.1:5555"; -const DEFAULT_PRODUCTION_GAME_API_URL = "https://gold-betting-keeper-production.up.railway.app"; +const DEFAULT_PRODUCTION_GAME_API_URL = "https://api.hyperbet.win"; const baseConfig: Partial = { betWindowSeconds: 300, diff --git a/packages/hyperbet-ui/src/lib/config.ts b/packages/hyperbet-ui/src/lib/config.ts index 23012b7b..debcf585 100644 --- a/packages/hyperbet-ui/src/lib/config.ts +++ b/packages/hyperbet-ui/src/lib/config.ts @@ -289,7 +289,7 @@ export interface EnvConfig { const DEFAULT_STREAM_URL = "https://www.twitch.tv/hyperscapeai"; const DEFAULT_STREAM_FALLBACK_URL = ""; const DEFAULT_GAME_API_URL = "http://127.0.0.1:5555"; -const DEFAULT_PRODUCTION_GAME_API_URL = "https://gold-betting-keeper-production.up.railway.app"; +const DEFAULT_PRODUCTION_GAME_API_URL = "https://api.hyperbet.win"; const DEFAULT_LOCAL_STREAM_URL = "http://127.0.0.1:3333/stream.html?disableBridgeCapture=1"; From 6917f6ddb9ac1d2a4a70a6199d593f359fea8e2f Mon Sep 17 00:00:00 2001 From: rndrntwrk <180591682+rndrntwrk@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:49:39 -0500 Subject: [PATCH 004/161] fix(bsc): back off rpc reads and hide mobile chart --- packages/hyperbet-bsc/app/src/App.tsx | 128 +++++----- packages/hyperbet-bsc/keeper/src/service.ts | 43 +++- .../src/components/EvmBettingPanel.tsx | 192 ++++++++------- packages/hyperbet-ui/src/lib/evmClient.ts | 222 +++++++++++++++++- packages/hyperbet-ui/src/styles.css | 3 + 5 files changed, 430 insertions(+), 158 deletions(-) diff --git a/packages/hyperbet-bsc/app/src/App.tsx b/packages/hyperbet-bsc/app/src/App.tsx index 5ecd4d3c..416509a9 100644 --- a/packages/hyperbet-bsc/app/src/App.tsx +++ b/packages/hyperbet-bsc/app/src/App.tsx @@ -1733,70 +1733,72 @@ export function App() { {/* Odds Chart */} -
-
- - - -
-
- - {(effYesPercent / 100).toFixed(1)} - -
-
- - - { - const d = new Date(v); - return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; - }} - /> - `${v}%`} - /> - - active && payload?.length ? ( -
- {payload[0].value}% -
- ) : null - } - /> - - -
-
+ {!isMobile && ( +
+
+ + + +
+
+ + {(effYesPercent / 100).toFixed(1)} + +
+
+ + + { + const d = new Date(v); + return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; + }} + /> + `${v}%`} + /> + + active && payload?.length ? ( +
+ {payload[0].value}% +
+ ) : null + } + /> + + +
+
+
-
+ )}
& { method: string; }; @@ -1664,9 +1669,12 @@ function checkRateLimit( pathname: string, limitPerMinute: number, burst: number, -): boolean { +): RateLimitResult { if (DISABLE_RATE_LIMIT) { - return true; + return { + allowed: true, + retryAfterSeconds: 0, + }; } const now = Date.now(); @@ -1689,12 +1697,23 @@ function checkRateLimit( if (bucket.tokens < 1) { rateBuckets.set(key, bucket); - return false; + const deficit = 1 - bucket.tokens; + const retryAfterSeconds = Math.max( + 1, + Math.ceil((deficit * 60) / limitPerMinute), + ); + return { + allowed: false, + retryAfterSeconds, + }; } bucket.tokens -= 1; rateBuckets.set(key, bucket); - return true; + return { + allowed: true, + retryAfterSeconds: 0, + }; } function requireWriteAuth( @@ -3090,14 +3109,24 @@ const server = Bun.serve({ fetch: async (req: Request) => { const url = new URL(req.url); const isWriteRoute = isWriteRateLimitedRoute(req.method, url.pathname); - const allowed = checkRateLimit( + const rateLimit = checkRateLimit( req, url.pathname, isWriteRoute ? WRITE_RATE_LIMIT_PER_MINUTE : READ_RATE_LIMIT_PER_MINUTE, isWriteRoute ? WRITE_RATE_LIMIT_BURST : READ_RATE_LIMIT_BURST, ); - if (!allowed) { - return jsonResponse(req, { error: "Rate limit exceeded" }, 429); + if (!rateLimit.allowed) { + return jsonResponse( + req, + { + error: "Rate limit exceeded", + retryAfterSeconds: rateLimit.retryAfterSeconds, + }, + 429, + { + "retry-after": String(rateLimit.retryAfterSeconds), + }, + ); } if (req.method === "OPTIONS") { diff --git a/packages/hyperbet-ui/src/components/EvmBettingPanel.tsx b/packages/hyperbet-ui/src/components/EvmBettingPanel.tsx index 84df9d16..1f934e05 100644 --- a/packages/hyperbet-ui/src/components/EvmBettingPanel.tsx +++ b/packages/hyperbet-ui/src/components/EvmBettingPanel.tsx @@ -37,14 +37,13 @@ import { createEvmPublicClient, createSignedRpcWalletClient, createUnlockedRpcWalletClient, - getFeeBps, getMarketMeta, + getMarketReadSnapshot, getNativeBalance, - getOrderBook, - getPosition, getRecentTrades, ORDER_FLAG_GTC, placeOrder, + RateLimitError, toDuelKeyHex, type MarketMeta, type MarketStatus, @@ -77,6 +76,7 @@ import { type Trade } from "./RecentTrades"; type BetSide = "YES" | "NO"; const MARKET_KIND_DUEL_WINNER = 0; +const MIN_RPC_BACKOFF_MS = 15_000; function createStrictPrivateKeyAccount( address: Address, @@ -392,6 +392,14 @@ function getLifecycleStatusLabel( } } +function toRateLimitError(error: unknown): RateLimitError | null { + return error instanceof RateLimitError ? error : null; +} + +function describeRefreshError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + export function EvmBettingPanel({ agent1Name, agent2Name, @@ -527,6 +535,7 @@ export function EvmBettingPanel({ const lastSnapshotRef = useRef<{ a: bigint; b: bigint }>({ a: 0n, b: 0n }); const refreshDataRef = useRef<() => Promise>(async () => {}); const refreshDataInFlightRef = useRef | null>(null); + const rpcBackoffUntilRef = useRef(0); const cycle = streamingState?.cycle ?? null; const streamedDuelKeyHex = @@ -698,6 +707,45 @@ export function EvmBettingPanel({ const refreshData = useCallback(async () => { if (!publicClient || !chainConfig) return; + const applyRateLimitBackoff = (error: unknown): boolean => { + const rateLimitError = toRateLimitError(error); + if (!rateLimitError) return false; + rpcBackoffUntilRef.current = Math.max( + rpcBackoffUntilRef.current, + Date.now() + Math.max(rateLimitError.retryAfterMs, MIN_RPC_BACKOFF_MS), + ); + setLastRefreshError(rateLimitError.message); + return true; + }; + + const updateStatusFromMarket = (market: MarketMeta, nextPosition: Position | null) => { + const nextUiState = derivePredictionMarketUiState( + activeLifecycleMarket, + nextPosition + ? { + aShares: nextPosition.aShares, + bShares: nextPosition.bShares, + aStake: nextPosition.aStake, + bStake: nextPosition.bStake, + refundableAmount: nextPosition.aStake + nextPosition.bStake, + } + : EMPTY_PREDICTION_MARKET_WALLET_SNAPSHOT, + { + lifecycleStatus: getFallbackLifecycleStatus(market.status), + winner: getFallbackWinner(market.winner), + }, + ); + setStatus( + getLifecycleStatusLabel( + nextUiState.lifecycleStatus, + nextUiState.winner, + cycleAgent1, + cycleAgent2, + copy, + ) ?? copy.waitingForMarketOperator, + ); + }; + try { if (!duelKeyHex) { setLastRefreshError("missing-duel-key"); @@ -732,115 +780,75 @@ export function EvmBettingPanel({ setMarketMeta(market); setLastRefreshError(null); updateChartAndTrades(market.totalAShares, market.totalBShares); - const feeBpsPromise = getFeeBps(publicClient, contractAddr); - const orderBookPromise = getOrderBook( - publicClient, + + if (!effectiveAddress) { + setPosition(null); + setNativeBalance(0n); + updateStatusFromMarket(market, null); + } else { + updateStatusFromMarket(market, effectivePosition); + } + + if (Date.now() < rpcBackoffUntilRef.current) { + return; + } + + const marketReadPromise = getMarketReadSnapshot( + chainConfig, contractAddr, duelKey, MARKET_KIND_DUEL_WINNER, market, + effectiveAddress, ); const tradesPromise = getRecentTrades( publicClient, contractAddr, market.marketKey, ); + const balancePromise = effectiveAddress + ? getNativeBalance(publicClient, effectiveAddress) + : Promise.resolve(0n); - if (effectiveAddress) { - const [userPosition, balance] = await Promise.all([ - getPosition( - publicClient, - contractAddr, - market.marketKey, - effectiveAddress, - ), - getNativeBalance(publicClient, effectiveAddress), - ]); - setPosition(userPosition); - setOptimisticPosition((current) => { - if (!current) return null; - const hasCaughtUp = - userPosition.aShares >= current.aShares && - userPosition.bShares >= current.bShares && - userPosition.aStake >= current.aStake && - userPosition.bStake >= current.bStake; - return hasCaughtUp ? null : current; - }); - setNativeBalance(balance); - const nextUiState = derivePredictionMarketUiState( - activeLifecycleMarket, - { - aShares: userPosition.aShares, - bShares: userPosition.bShares, - aStake: userPosition.aStake, - bStake: userPosition.bStake, - refundableAmount: userPosition.aStake + userPosition.bStake, - }, - { - lifecycleStatus: getFallbackLifecycleStatus(market.status), - winner: getFallbackWinner(market.winner), - }, - ); - setStatus( - getLifecycleStatusLabel( - nextUiState.lifecycleStatus, - nextUiState.winner, - cycleAgent1, - cycleAgent2, - copy, - ) ?? copy.waitingForMarketOperator, - ); - } else { - setPosition(null); - setNativeBalance(0n); - const nextUiState = derivePredictionMarketUiState( - activeLifecycleMarket, - EMPTY_PREDICTION_MARKET_WALLET_SNAPSHOT, - { - lifecycleStatus: getFallbackLifecycleStatus(market.status), - winner: getFallbackWinner(market.winner), - }, - ); - setStatus( - getLifecycleStatusLabel( - nextUiState.lifecycleStatus, - nextUiState.winner, - cycleAgent1, - cycleAgent2, - copy, - ) ?? copy.waitingForMarketOperator, - ); - } - - const [feeBpsResult, orderBookResult, tradesResult] = + const [marketReadResult, tradesResult, balanceResult] = await Promise.allSettled([ - feeBpsPromise, - orderBookPromise, + marketReadPromise, tradesPromise, + balancePromise, ]); - if (feeBpsResult.status === "fulfilled") { - setTradeFeeBps(feeBpsResult.value); - } - - if (orderBookResult.status === "fulfilled") { + if (marketReadResult.status === "fulfilled") { + setTradeFeeBps(marketReadResult.value.feeBps); setBids( - orderBookResult.value.bids.map((entry) => ({ + marketReadResult.value.orderBook.bids.map((entry) => ({ price: entry.price, amount: Number(formatUnits(entry.amount, nativeDecimals)), total: Number(formatUnits(entry.total, nativeDecimals)), })), ); setAsks( - orderBookResult.value.asks.map((entry) => ({ + marketReadResult.value.orderBook.asks.map((entry) => ({ price: entry.price, amount: Number(formatUnits(entry.amount, nativeDecimals)), total: Number(formatUnits(entry.total, nativeDecimals)), })), ); - } else { - setBids([]); - setAsks([]); + if (effectiveAddress && marketReadResult.value.position) { + const userPosition = marketReadResult.value.position; + setPosition(userPosition); + setOptimisticPosition((current) => { + if (!current) return null; + const hasCaughtUp = + userPosition.aShares >= current.aShares && + userPosition.bShares >= current.bShares && + userPosition.aStake >= current.aStake && + userPosition.bStake >= current.bStake; + return hasCaughtUp ? null : current; + }); + updateStatusFromMarket(market, userPosition); + } + } else if (!applyRateLimitBackoff(marketReadResult.reason)) { + setLastRefreshError(describeRefreshError(marketReadResult.reason)); } if (tradesResult.status === "fulfilled") { @@ -853,10 +861,19 @@ export function EvmBettingPanel({ time: trade.time, })), ); - } else { - setRecentTrades([]); + } else if (!applyRateLimitBackoff(tradesResult.reason)) { + setLastRefreshError(describeRefreshError(tradesResult.reason)); + } + + if (balanceResult.status === "fulfilled") { + setNativeBalance(balanceResult.value); + } else if (!applyRateLimitBackoff(balanceResult.reason)) { + setLastRefreshError(describeRefreshError(balanceResult.reason)); } } catch (error) { + if (applyRateLimitBackoff(error)) { + return; + } const message = (error as Error).message; setLastRefreshError(message); setStatus(copy.refreshFailed(message)); @@ -868,6 +885,7 @@ export function EvmBettingPanel({ cycleAgent2, duelKeyHex, effectiveAddress, + effectivePosition, activeLifecycleMarket, lifecycleStatusLabel, nativeDecimals, diff --git a/packages/hyperbet-ui/src/lib/evmClient.ts b/packages/hyperbet-ui/src/lib/evmClient.ts index c7be72f2..72acecb4 100644 --- a/packages/hyperbet-ui/src/lib/evmClient.ts +++ b/packages/hyperbet-ui/src/lib/evmClient.ts @@ -2,6 +2,7 @@ import { createPublicClient, createWalletClient, custom, + decodeFunctionResult, encodeAbiParameters, encodeFunctionData, fallback, @@ -55,6 +56,21 @@ export type Position = { bStake: bigint; }; +export type OrderBookLevel = { + price: number; + amount: bigint; + total: bigint; +}; + +export type MarketReadSnapshot = { + feeBps: number; + position: Position | null; + orderBook: { + bids: OrderBookLevel[]; + asks: OrderBookLevel[]; + }; +}; + export type TimeInForce = "gtc" | "ioc"; export type OrderInfo = { @@ -77,10 +93,30 @@ export type ContractWriteClient = { export type ContractWriteAccount = Address | Account; type JsonRpcPayload = { + id?: number | string | null; result?: T; error?: { message?: string }; }; +type GoldClobBatchRead = { + functionName: + | "tradeTreasuryFeeBps" + | "tradeMarketMakerFeeBps" + | "positions" + | "getPriceLevel"; + args?: readonly unknown[]; +}; + +export class RateLimitError extends Error { + readonly retryAfterMs: number; + + constructor(message = "Rate limit exceeded", retryAfterMs = 15_000) { + super(message); + this.name = "RateLimitError"; + this.retryAfterMs = retryAfterMs; + } +} + export const SIDE_ENUM = { NONE: 0, A: 1, @@ -142,9 +178,13 @@ function deriveAlchemyWsUrl(rpcUrl: string): string | null { } } +function isProxyReadRpcUrl(rpcUrl: string): boolean { + return rpcUrl.includes("/api/proxy/evm/rpc"); +} + function createEvmTransport(rpcUrl: string) { const httpTransport = http(rpcUrl, { - retryCount: 5, + retryCount: isProxyReadRpcUrl(rpcUrl) ? 0 : 5, retryDelay: 250, timeout: 20_000, }); @@ -265,6 +305,100 @@ async function callEvmRpc( } } +function parseRetryAfterMs(rawValue: string | null): number | null { + if (!rawValue) return null; + const seconds = Number(rawValue); + if (Number.isFinite(seconds) && seconds >= 0) { + return Math.max(1_000, Math.ceil(seconds * 1_000)); + } + + const retryAtMs = Date.parse(rawValue); + if (!Number.isNaN(retryAtMs)) { + return Math.max(1_000, retryAtMs - Date.now()); + } + return null; +} + +function rateLimitErrorFromResponse(response: Response): RateLimitError { + return new RateLimitError( + "EVM read rate limited", + parseRetryAfterMs(response.headers.get("retry-after")) ?? 15_000, + ); +} + +async function callGoldClobBatchRead( + rpcUrl: string, + contractAddress: Address, + calls: readonly GoldClobBatchRead[], +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15_000); + + try { + const payload = calls.map((call, index) => ({ + jsonrpc: "2.0" as const, + id: index, + method: "eth_call", + params: [ + { + to: contractAddress, + data: encodeFunctionData({ + abi: GOLD_CLOB_ABI, + functionName: call.functionName, + args: (call.args ?? []) as readonly unknown[], + } as unknown as Parameters[0]), + }, + "latest", + ], + })); + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + if (response.status === 429) { + throw rateLimitErrorFromResponse(response); + } + + const body = (await response.json()) as JsonRpcPayload[]; + if (!response.ok || !Array.isArray(body)) { + throw new Error("eth_call batch failed"); + } + + const byId = new Map>(); + for (const entry of body) { + const id = Number(entry.id); + if (Number.isInteger(id)) { + byId.set(id, entry); + } + } + + return calls.map((call, index) => { + const entry = byId.get(index); + if (!entry) { + throw new Error(`${call.functionName} missing from eth_call batch`); + } + if (entry.result === undefined) { + throw new Error(entry.error?.message || `${call.functionName} failed`); + } + return decodeFunctionResult({ + abi: GOLD_CLOB_ABI, + functionName: call.functionName, + data: entry.result, + } as unknown as Parameters[0]); + }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error("eth_call batch timed out after 15000ms"); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} + export function createSignedRpcWalletClient( chainConfig: EvmChainConfig, account: LocalAccount, @@ -392,6 +526,92 @@ export async function getMarketMeta( }; } +export async function getMarketReadSnapshot( + chainConfig: EvmChainConfig, + contractAddress: Address, + duelKey: Hex, + marketKind: number, + market: MarketMeta, + userAddress?: Address, +): Promise { + const calls: GoldClobBatchRead[] = [ + { functionName: "tradeTreasuryFeeBps" }, + { functionName: "tradeMarketMakerFeeBps" }, + ]; + const hasUserAddress = Boolean(userAddress); + if (userAddress) { + calls.push({ + functionName: "positions", + args: [market.marketKey, userAddress], + }); + } + + const bidPrices: number[] = []; + for (let price = market.bestBid; price > 0 && bidPrices.length < 10; price -= 1) { + bidPrices.push(price); + calls.push({ + functionName: "getPriceLevel", + args: [duelKey, marketKind, SIDE_ENUM.BUY, price], + }); + } + + const askPrices: number[] = []; + for (let price = market.bestAsk; price < 1000 && askPrices.length < 10; price += 1) { + askPrices.push(price); + calls.push({ + functionName: "getPriceLevel", + args: [duelKey, marketKind, SIDE_ENUM.SELL, price], + }); + } + + const results = await callGoldClobBatchRead( + chainConfig.readRpcUrl, + contractAddress, + calls, + ); + + let nextIndex = 0; + const treasuryFee = results[nextIndex++] as bigint; + const marketMakerFee = results[nextIndex++] as bigint; + const position = hasUserAddress + ? ((results[nextIndex++] as [bigint, bigint, bigint, bigint]) ?? null) + : null; + + const bids: OrderBookLevel[] = []; + let runningBid = 0n; + for (const price of bidPrices) { + const level = results[nextIndex++] as [bigint, bigint, bigint]; + if (level[2] <= 0n) continue; + runningBid += level[2]; + bids.push({ price: price / 1000, amount: level[2], total: runningBid }); + } + + const asks: OrderBookLevel[] = []; + let runningAsk = 0n; + for (const price of askPrices) { + const level = results[nextIndex++] as [bigint, bigint, bigint]; + if (level[2] <= 0n) continue; + runningAsk += level[2]; + asks.push({ price: price / 1000, amount: level[2], total: runningAsk }); + } + + return { + feeBps: Number(treasuryFee + marketMakerFee), + position: position + ? { + aShares: position[0], + bShares: position[1], + aStake: position[2], + bStake: position[3], + } + : null, + orderBook: { + bids, + asks, + }, + }; +} + export async function getPosition( client: PublicClient, contractAddress: Address, diff --git a/packages/hyperbet-ui/src/styles.css b/packages/hyperbet-ui/src/styles.css index b6d4e37c..ead717c5 100644 --- a/packages/hyperbet-ui/src/styles.css +++ b/packages/hyperbet-ui/src/styles.css @@ -3028,6 +3028,9 @@ select:focus-visible { .hm-chart-container { flex: 1; + width: 100%; + height: 100%; + min-width: 0; min-height: 0; padding: 8px; } From 6b3a2923711a2fffb5426e61e95e40358c86df17 Mon Sep 17 00:00:00 2001 From: rndrntwrk <180591682+rndrntwrk@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:40:35 -0500 Subject: [PATCH 005/161] fix: harden enoomian staging stream embedding --- packages/hyperbet-bsc/app/src/lib/config.ts | 57 +++++- .../hyperbet-solana/app/src/lib/config.ts | 57 +++++- .../src/components/StreamPlayer.tsx | 168 ++++++++++++++++-- 3 files changed, 263 insertions(+), 19 deletions(-) diff --git a/packages/hyperbet-bsc/app/src/lib/config.ts b/packages/hyperbet-bsc/app/src/lib/config.ts index c0d77f1a..15dab08f 100644 --- a/packages/hyperbet-bsc/app/src/lib/config.ts +++ b/packages/hyperbet-bsc/app/src/lib/config.ts @@ -77,6 +77,53 @@ function uniqueList(values: string[]): string[] { return unique; } +function parseStreamSourceUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + return null; + } +} + +function isHyperscapeStreamSource(value: string): boolean { + const parsed = parseStreamSourceUrl(value); + if (!parsed) return false; + + const pathname = parsed.pathname.toLowerCase(); + const page = (parsed.searchParams.get("page") || "").trim().toLowerCase(); + const isStreamRoute = + pathname.endsWith("/stream") || + pathname === "/stream" || + pathname.endsWith("/stream.html") || + pathname === "/stream.html" || + page === "stream"; + + return ( + isStreamRoute && + (parsed.searchParams.has("streamToken") || + parsed.hostname.toLowerCase().includes("hyperscape")) + ); +} + +function sanitizeResolvedStreamSources(values: string[]): string[] { + const uniqueSources = uniqueList(values); + if (!uniqueSources.some(isHyperscapeStreamSource)) { + return uniqueSources; + } + + const retainedSources = uniqueSources.filter(isHyperscapeStreamSource); + const droppedSources = uniqueSources.filter( + (source) => !isHyperscapeStreamSource(source), + ); + if (droppedSources.length > 0 && typeof console !== "undefined") { + console.warn( + "[config] dropping non-Hyperscapes fallback streams while a tokenized Hyperscapes stream is configured", + droppedSources, + ); + } + return retainedSources; +} + function resolveEnvironment(): Environment { const explicitCluster = readEnvString("VITE_SOLANA_CLUSTER")?.toLowerCase(); if (explicitCluster && ENVIRONMENT_ALIASES[explicitCluster]) { @@ -276,7 +323,7 @@ interface EnvConfig { const DEFAULT_STREAM_URL = "https://www.twitch.tv/hyperscapeai"; const DEFAULT_STREAM_FALLBACK_URL = ""; const DEFAULT_GAME_API_URL = "http://127.0.0.1:5555"; -const DEFAULT_PRODUCTION_GAME_API_URL = "https://bsc-api.hyperbet.win"; +const DEFAULT_PRODUCTION_GAME_API_URL = "https://gold-betting-keeper-production.up.railway.app"; const baseConfig: Partial = { betWindowSeconds: 300, @@ -410,7 +457,7 @@ const defaultPrimaryStreamUrl = envStreamUrl ?? (suppressDefaultStreamFallback ? "" : baseEnvConfig.streamUrl); const resolvedStreamSources = (() => { if (envStreamSources.length > 0) { - return uniqueList(envStreamSources); + return sanitizeResolvedStreamSources(envStreamSources); } const envFallbackUrl = readEnvString("VITE_STREAM_FALLBACK_URL"); const fallbackUrl = @@ -418,8 +465,10 @@ const resolvedStreamSources = (() => { (defaultPrimaryStreamUrl && !suppressDefaultStreamFallback ? DEFAULT_STREAM_FALLBACK_URL : ""); - return uniqueList([defaultPrimaryStreamUrl, fallbackUrl ?? ""]).filter( - (value) => value.length > 0, + return sanitizeResolvedStreamSources( + uniqueList([defaultPrimaryStreamUrl, fallbackUrl ?? ""]).filter( + (value) => value.length > 0, + ), ); })(); const resolvedStreamUrl = resolvedStreamSources[0] ?? ""; diff --git a/packages/hyperbet-solana/app/src/lib/config.ts b/packages/hyperbet-solana/app/src/lib/config.ts index 90e9c75a..bdba0744 100644 --- a/packages/hyperbet-solana/app/src/lib/config.ts +++ b/packages/hyperbet-solana/app/src/lib/config.ts @@ -72,6 +72,53 @@ function uniqueList(values: string[]): string[] { return unique; } +function parseStreamSourceUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + return null; + } +} + +function isHyperscapeStreamSource(value: string): boolean { + const parsed = parseStreamSourceUrl(value); + if (!parsed) return false; + + const pathname = parsed.pathname.toLowerCase(); + const page = (parsed.searchParams.get("page") || "").trim().toLowerCase(); + const isStreamRoute = + pathname.endsWith("/stream") || + pathname === "/stream" || + pathname.endsWith("/stream.html") || + pathname === "/stream.html" || + page === "stream"; + + return ( + isStreamRoute && + (parsed.searchParams.has("streamToken") || + parsed.hostname.toLowerCase().includes("hyperscape")) + ); +} + +function sanitizeResolvedStreamSources(values: string[]): string[] { + const uniqueSources = uniqueList(values); + if (!uniqueSources.some(isHyperscapeStreamSource)) { + return uniqueSources; + } + + const retainedSources = uniqueSources.filter(isHyperscapeStreamSource); + const droppedSources = uniqueSources.filter( + (source) => !isHyperscapeStreamSource(source), + ); + if (droppedSources.length > 0 && typeof console !== "undefined") { + console.warn( + "[config] dropping non-Hyperscapes fallback streams while a tokenized Hyperscapes stream is configured", + droppedSources, + ); + } + return retainedSources; +} + function resolveEnvironment(): Environment { const explicitCluster = readEnvString("VITE_SOLANA_CLUSTER")?.toLowerCase(); if (explicitCluster && ENVIRONMENT_ALIASES[explicitCluster]) { @@ -182,7 +229,7 @@ export interface EnvConfig { const DEFAULT_STREAM_URL = "https://www.twitch.tv/hyperscapeai"; const DEFAULT_STREAM_FALLBACK_URL = ""; const DEFAULT_GAME_API_URL = "http://127.0.0.1:5555"; -const DEFAULT_PRODUCTION_GAME_API_URL = "https://api.hyperbet.win"; +const DEFAULT_PRODUCTION_GAME_API_URL = "https://gold-betting-keeper-production.up.railway.app"; const baseConfig: Partial = { betWindowSeconds: 300, @@ -306,7 +353,7 @@ const defaultPrimaryStreamUrl = envStreamUrl ?? (suppressDefaultStreamFallback ? "" : baseEnvConfig.streamUrl); const resolvedStreamSources = (() => { if (envStreamSources.length > 0) { - return uniqueList(envStreamSources); + return sanitizeResolvedStreamSources(envStreamSources); } const envFallbackUrl = readEnvString("VITE_STREAM_FALLBACK_URL"); const fallbackUrl = @@ -314,8 +361,10 @@ const resolvedStreamSources = (() => { (defaultPrimaryStreamUrl && !suppressDefaultStreamFallback ? DEFAULT_STREAM_FALLBACK_URL : ""); - return uniqueList([defaultPrimaryStreamUrl, fallbackUrl ?? ""]).filter( - (value) => value.length > 0, + return sanitizeResolvedStreamSources( + uniqueList([defaultPrimaryStreamUrl, fallbackUrl ?? ""]).filter( + (value) => value.length > 0, + ), ); })(); const resolvedStreamUrl = resolvedStreamSources[0] ?? ""; diff --git a/packages/hyperbet-ui/src/components/StreamPlayer.tsx b/packages/hyperbet-ui/src/components/StreamPlayer.tsx index 0d202acc..d34712c4 100644 --- a/packages/hyperbet-ui/src/components/StreamPlayer.tsx +++ b/packages/hyperbet-ui/src/components/StreamPlayer.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import Hls from "hls.js"; interface StreamPlayerProps { @@ -27,16 +33,32 @@ export const StreamPlayer: React.FC = ({ () => resolveEmbedUrl(streamUrl, autoPlay, muted), [autoPlay, muted, streamUrl], ); + const embedKind = useMemo(() => classifyEmbedKind(embedUrl), [embedUrl]); const unavailableNotifiedRef = useRef(false); + const readyNotifiedRef = useRef(false); + const [embedFailure, setEmbedFailure] = useState(null); + + const markUnavailable = useCallback( + (reason = "Live stream unavailable.") => { + setEmbedFailure((current) => current ?? reason); + if (unavailableNotifiedRef.current) return; + unavailableNotifiedRef.current = true; + onStreamUnavailable?.(); + }, + [onStreamUnavailable], + ); - const markUnavailable = useCallback(() => { - if (unavailableNotifiedRef.current) return; - unavailableNotifiedRef.current = true; - onStreamUnavailable?.(); - }, [onStreamUnavailable]); + const markReady = useCallback(() => { + setEmbedFailure(null); + if (readyNotifiedRef.current) return; + readyNotifiedRef.current = true; + onStreamReady?.(); + }, [onStreamReady]); useEffect(() => { unavailableNotifiedRef.current = false; + readyNotifiedRef.current = false; + setEmbedFailure(null); }, [streamUrl]); useEffect(() => { @@ -44,6 +66,56 @@ export const StreamPlayer: React.FC = ({ markUnavailable(); }, [embedUrl, markUnavailable]); + useEffect(() => { + if (!embedUrl || embedKind !== "hyperscape" || typeof window === "undefined") { + return; + } + + const embedOrigin = getEmbedOrigin(embedUrl); + if (!embedOrigin) { + return; + } + + let seenStatusMessage = false; + const bootstrapTimeout = window.setTimeout(() => { + if (!seenStatusMessage) { + markUnavailable("Failed to initialize the embedded Hyperscapes stream."); + } + }, 10_000); + + const handleMessage = (event: MessageEvent) => { + if (event.origin !== embedOrigin) return; + if (!event.data || typeof event.data !== "object") return; + + const payload = event.data as { + type?: string; + ready?: boolean; + status?: string | null; + }; + if (payload.type !== "HYPERSCAPE_STREAM_STATUS") { + return; + } + + seenStatusMessage = true; + window.clearTimeout(bootstrapTimeout); + + if (payload.ready === true) { + markReady(); + return; + } + + if (typeof payload.status === "string" && payload.status.startsWith("error:")) { + markUnavailable(describeHyperscapeEmbedError(payload.status)); + } + }; + + window.addEventListener("message", handleMessage); + return () => { + window.clearTimeout(bootstrapTimeout); + window.removeEventListener("message", handleMessage); + }; + }, [embedKind, embedUrl, markReady, markUnavailable]); + useEffect(() => { // External embeddable URLs render through iframe mode below. if (embedUrl) return; @@ -220,7 +292,7 @@ export const StreamPlayer: React.FC = ({ hls.on(Hls.Events.MANIFEST_PARSED, () => { console.log("[StreamPlayer] Manifest parsed, starting playback"); - onStreamReady?.(); + markReady(); void video.play().catch(() => {}); }); @@ -269,7 +341,7 @@ export const StreamPlayer: React.FC = ({ const onWaiting = () => nudgeToLiveEdge(); const onStalled = () => nudgeToLiveEdge(); - const onLoadedMetadata = () => onStreamReady?.(); + const onLoadedMetadata = () => markReady(); const onVideoError = () => scheduleRebuild("video element error", 1000); video.addEventListener("waiting", onWaiting); @@ -296,7 +368,7 @@ export const StreamPlayer: React.FC = ({ hls = null; } }; - }, [embedUrl, streamUrl, autoPlay, muted]); + }, [embedUrl, streamUrl, autoPlay, muted, markReady]); if (!embedUrl) { return ( @@ -350,9 +422,9 @@ export const StreamPlayer: React.FC = ({ allow="autoplay; encrypted-media; picture-in-picture; clipboard-write" allowFullScreen loading="eager" - onLoad={onStreamReady} + onLoad={embedKind === "hyperscape" ? undefined : markReady} referrerPolicy="strict-origin-when-cross-origin" - onError={markUnavailable} + onError={() => markUnavailable()} style={{ width: "100%", height: "100%", @@ -361,6 +433,39 @@ export const StreamPlayer: React.FC = ({ backgroundColor: "#000", }} /> + {embedFailure ? ( +
+
+ {embedFailure} +
+
+ ) : null}
Date: Wed, 1 Apr 2026 22:00:42 -0500 Subject: [PATCH 006/161] fix: defer prediction chart mount until sized --- .../src/components/PredictionMarketPanel.tsx | 165 +++++++++++------- 1 file changed, 99 insertions(+), 66 deletions(-) diff --git a/packages/hyperbet-ui/src/components/PredictionMarketPanel.tsx b/packages/hyperbet-ui/src/components/PredictionMarketPanel.tsx index ce3e215c..e38a4202 100644 --- a/packages/hyperbet-ui/src/components/PredictionMarketPanel.tsx +++ b/packages/hyperbet-ui/src/components/PredictionMarketPanel.tsx @@ -1,4 +1,4 @@ -import { useState, ReactNode } from "react"; +import { useEffect, useRef, useState, ReactNode } from "react"; import { getUiCopy, resolveUiLocale, type UiLocale } from "@hyperbet/ui/i18n"; import { LineChart, @@ -88,6 +88,8 @@ export function PredictionMarketPanel({ const resolvedLocale = resolveUiLocale(locale); const copy = getUiCopy(resolvedLocale); const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy"); + const chartContainerRef = useRef(null); + const [chartReady, setChartReady] = useState(false); const yesSelected = side === "YES"; const noSelected = side === "NO"; @@ -118,6 +120,33 @@ export function PredictionMarketPanel({ const C_YES_BAR = compact ? "linear-gradient(90deg, var(--hm-buy-soft, rgba(34,197,94,0.2)), var(--hm-buy), var(--hm-buy-soft, rgba(34,197,94,0.2)))" : "linear-gradient(90deg, var(--hm-buy-soft, rgba(34,197,94,0.2)), var(--hm-buy), var(--hm-buy-soft, rgba(34,197,94,0.2)))"; + + useEffect(() => { + const container = chartContainerRef.current; + if (!container) return; + + const updateChartReady = () => { + const nextReady = + container.clientWidth > 0 && container.clientHeight > 0; + setChartReady((prevReady) => + prevReady === nextReady ? prevReady : nextReady, + ); + }; + + updateChartReady(); + + const resizeObserver = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(updateChartReady) + : null; + resizeObserver?.observe(container); + + window.addEventListener("resize", updateChartReady); + return () => { + resizeObserver?.disconnect(); + window.removeEventListener("resize", updateChartReady); + }; + }, []); const C_YES_BAR_SHADOW = compact ? "0 0 8px var(--hm-buy-glow-strong, rgba(34,197,94,0.5))" : "0 0 8px var(--hm-buy-glow-strong, rgba(34,197,94,0.5))"; @@ -893,6 +922,7 @@ export function PredictionMarketPanel({ />
- - - - - - - - - - - { - if (active && payload && payload.length) { - return ( -
- - {payload[0].value}% - -
- ); - } - return null; - }} - /> - - -
-
+ {chartReady ? ( + + + + + + + + + + + { + if (active && payload && payload.length) { + return ( +
+ + {payload[0].value}% + +
+ ); + } + return null; + }} + /> + + +
+
+ ) : null}
From ad66afae868f6fb0d480b17d4f13c39785deecd4 Mon Sep 17 00:00:00 2001 From: rndrntwrk <180591682+rndrntwrk@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:04:34 -0500 Subject: [PATCH 007/161] fix: delay bsc odds chart until layout settles --- packages/hyperbet-bsc/app/src/App.tsx | 127 +++++++++++++++++--------- 1 file changed, 82 insertions(+), 45 deletions(-) diff --git a/packages/hyperbet-bsc/app/src/App.tsx b/packages/hyperbet-bsc/app/src/App.tsx index 416509a9..13c1a2da 100644 --- a/packages/hyperbet-bsc/app/src/App.tsx +++ b/packages/hyperbet-bsc/app/src/App.tsx @@ -720,9 +720,38 @@ export function App() { >("leaderboard"); const appRootRef = useRef(null); const bettingDockInnerRef = useRef(null); + const chartContainerRef = useRef(null); + const [chartReady, setChartReady] = useState(false); const { state: streamingState } = useStreamingState(); const { context: duelContext } = useDuelContext(); + + useEffect(() => { + const container = chartContainerRef.current; + if (!container) return; + + const updateChartReady = () => { + const nextReady = + container.clientWidth > 0 && container.clientHeight > 0; + setChartReady((prevReady) => + prevReady === nextReady ? prevReady : nextReady, + ); + }; + + updateChartReady(); + + const resizeObserver = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(updateChartReady) + : null; + resizeObserver?.observe(container); + + window.addEventListener("resize", updateChartReady); + return () => { + resizeObserver?.disconnect(); + window.removeEventListener("resize", updateChartReady); + }; + }, []); const liveCycle = streamingState?.cycle ?? null; const lifecycleChainKey = activeChain === "bsc" || activeChain === "base" || activeChain === "avax" @@ -1751,51 +1780,59 @@ export function App() { {(effYesPercent / 100).toFixed(1)} -
- - - { - const d = new Date(v); - return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; - }} - /> - `${v}%`} - /> - - active && payload?.length ? ( -
- {payload[0].value}% -
- ) : null - } - /> - - -
-
+
+ {chartReady ? ( + + + { + const d = new Date(v); + return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; + }} + /> + `${v}%`} + /> + + active && payload?.length ? ( +
+ {payload[0].value}% +
+ ) : null + } + /> + + +
+
+ ) : null}
)} From f1c864b711253f817b17a035f219da0d1cbe8698 Mon Sep 17 00:00:00 2001 From: rndrntwrk <180591682+rndrntwrk@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:08:02 -0500 Subject: [PATCH 008/161] fix: guard models chart until it is measurable --- .../src/components/ModelsMarketView.tsx | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/hyperbet-ui/src/components/ModelsMarketView.tsx b/packages/hyperbet-ui/src/components/ModelsMarketView.tsx index 2136e9d7..a16ff27c 100644 --- a/packages/hyperbet-ui/src/components/ModelsMarketView.tsx +++ b/packages/hyperbet-ui/src/components/ModelsMarketView.tsx @@ -720,6 +720,9 @@ export function ModelsMarketView({ const [oracleHistoryError, setOracleHistoryError] = React.useState< string | null >(null); + const oracleHistoryChartRef = React.useRef(null); + const [oracleHistoryChartReady, setOracleHistoryChartReady] = + React.useState(false); const effectiveLeverage = Math.min( configuredMaxLeverage, Math.max(1, Math.round(leverage)), @@ -737,6 +740,33 @@ export function ModelsMarketView({ const openCollateralSatisfiesMinMargin = estimatedOpenPostFeeMarginLamports >= configuredMinMarginLamports; + React.useEffect(() => { + const container = oracleHistoryChartRef.current; + if (!container) return; + + const updateChartReady = () => { + const nextReady = + container.clientWidth > 0 && container.clientHeight > 0; + setOracleHistoryChartReady((prevReady) => + prevReady === nextReady ? prevReady : nextReady, + ); + }; + + updateChartReady(); + + const resizeObserver = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(updateChartReady) + : null; + resizeObserver?.observe(container); + + window.addEventListener("resize", updateChartReady); + return () => { + resizeObserver?.disconnect(); + window.removeEventListener("resize", updateChartReady); + }; + }, []); + React.useEffect(() => { if (E2E_MODEL_ENTRY) { setData({ @@ -1756,7 +1786,10 @@ export function ModelsMarketView({ -
+
{oracleHistoryError ? (
{copy.oracleHistoryError(oracleHistoryError)} @@ -1769,7 +1802,7 @@ export function ModelsMarketView({
{copy.waitingForSnapshots}
- ) : ( + ) : oracleHistoryChartReady ? ( - )} + ) : null}
From 7f3d1a5467c1c068d3cbbd0903b07a1585cf5b98 Mon Sep 17 00:00:00 2001 From: rndrntwrk <180591682+rndrntwrk@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:18:00 -0500 Subject: [PATCH 009/161] fix: stabilize market chart sizing --- packages/hyperbet-bsc/app/src/App.tsx | 135 ++++++------- packages/hyperbet-solana/app/src/App.tsx | 136 ++++++------- .../src/components/ModelsMarketView.tsx | 179 ++++++++---------- .../src/components/PredictionMarketPanel.tsx | 168 +++++++--------- .../src/lib/useMeasuredContentBox.ts | 94 +++++++++ 5 files changed, 371 insertions(+), 341 deletions(-) create mode 100644 packages/hyperbet-ui/src/lib/useMeasuredContentBox.ts diff --git a/packages/hyperbet-bsc/app/src/App.tsx b/packages/hyperbet-bsc/app/src/App.tsx index 13c1a2da..02b8c5b4 100644 --- a/packages/hyperbet-bsc/app/src/App.tsx +++ b/packages/hyperbet-bsc/app/src/App.tsx @@ -36,6 +36,7 @@ import { PointsDisplay } from "@hyperbet/ui/components/PointsDisplay"; import { useChain } from "./lib/ChainContext"; import { useStreamingState } from "@hyperbet/ui/spectator/useStreamingState"; import { useDuelContext } from "@hyperbet/ui/spectator/useDuelContext"; +import { useMeasuredContentBox } from "@hyperbet/ui/lib/useMeasuredContentBox"; import { useResizePanel, useIsMobile } from "@hyperbet/ui/lib/useResizePanel"; import { ResizeHandle } from "@hyperbet/ui/components/ResizeHandle"; import { @@ -44,7 +45,6 @@ import { XAxis, YAxis, Tooltip, - ResponsiveContainer, ReferenceLine, } from "recharts"; @@ -721,37 +721,10 @@ export function App() { const appRootRef = useRef(null); const bettingDockInnerRef = useRef(null); const chartContainerRef = useRef(null); - const [chartReady, setChartReady] = useState(false); + const chartSize = useMeasuredContentBox(chartContainerRef, !isMobile, 2); const { state: streamingState } = useStreamingState(); const { context: duelContext } = useDuelContext(); - - useEffect(() => { - const container = chartContainerRef.current; - if (!container) return; - - const updateChartReady = () => { - const nextReady = - container.clientWidth > 0 && container.clientHeight > 0; - setChartReady((prevReady) => - prevReady === nextReady ? prevReady : nextReady, - ); - }; - - updateChartReady(); - - const resizeObserver = - typeof ResizeObserver !== "undefined" - ? new ResizeObserver(updateChartReady) - : null; - resizeObserver?.observe(container); - - window.addEventListener("resize", updateChartReady); - return () => { - resizeObserver?.disconnect(); - window.removeEventListener("resize", updateChartReady); - }; - }, []); const liveCycle = streamingState?.cycle ?? null; const lifecycleChainKey = activeChain === "bsc" || activeChain === "base" || activeChain === "avax" @@ -1781,57 +1754,59 @@ export function App() {
- {chartReady ? ( - - - { - const d = new Date(v); - return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; - }} - /> - `${v}%`} - /> - - active && payload?.length ? ( -
- {payload[0].value}% -
- ) : null - } - /> - - -
-
+ {chartSize ? ( + + { + const d = new Date(v); + return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; + }} + /> + `${v}%`} + /> + + active && payload?.length ? ( +
+ {payload[0].value}% +
+ ) : null + } + /> + + +
) : null}
diff --git a/packages/hyperbet-solana/app/src/App.tsx b/packages/hyperbet-solana/app/src/App.tsx index 71eaf522..a914acc8 100644 --- a/packages/hyperbet-solana/app/src/App.tsx +++ b/packages/hyperbet-solana/app/src/App.tsx @@ -49,6 +49,7 @@ import { FIGHT_ORACLE_PROGRAM_ID } from "./lib/programIds"; import { useStreamingState } from "./spectator/useStreamingState"; import { useDuelContext } from "@hyperbet/ui/spectator/useDuelContext"; import type { LeaderboardEntry } from "./spectator/types"; +import { useMeasuredContentBox } from "@hyperbet/ui/lib/useMeasuredContentBox"; import { useResizePanel, useIsMobile } from "@hyperbet/ui/lib/useResizePanel"; import { ResizeHandle } from "@hyperbet/ui/components/ResizeHandle"; import { @@ -57,7 +58,6 @@ import { XAxis, YAxis, Tooltip, - ResponsiveContainer, ReferenceLine, } from "recharts"; @@ -745,6 +745,8 @@ export function App() { >("leaderboard"); const appRootRef = useRef(null); const bettingDockInnerRef = useRef(null); + const chartContainerRef = useRef(null); + const chartSize = useMeasuredContentBox(chartContainerRef, !isMobile, 2); const { state: streamingState } = useStreamingState(); const { context: duelContext } = useDuelContext(); @@ -1757,70 +1759,76 @@ export function App() { {/* Odds Chart */} -
-
- - - -
-
- - {(effYesPercent / 100).toFixed(1)} - -
-
- - - { - const d = new Date(v); - return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; - }} - /> - `${v}%`} - /> - - active && payload?.length ? ( -
- {payload[0].value}% -
- ) : null - } - /> - - -
-
+ {!isMobile && ( +
+
+ + + +
+
+ + {(effYesPercent / 100).toFixed(1)} + +
+
+ {chartSize ? ( + + { + const d = new Date(v); + return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; + }} + /> + `${v}%`} + /> + + active && payload?.length ? ( +
+ {payload[0].value}% +
+ ) : null + } + /> + + +
+ ) : null} +
-
+ )}
(null); const oracleHistoryChartRef = React.useRef(null); - const [oracleHistoryChartReady, setOracleHistoryChartReady] = - React.useState(false); + const oracleHistoryChartSize = useMeasuredContentBox( + oracleHistoryChartRef, + true, + 2, + ); const effectiveLeverage = Math.min( configuredMaxLeverage, Math.max(1, Math.round(leverage)), @@ -740,33 +743,6 @@ export function ModelsMarketView({ const openCollateralSatisfiesMinMargin = estimatedOpenPostFeeMarginLamports >= configuredMinMarginLamports; - React.useEffect(() => { - const container = oracleHistoryChartRef.current; - if (!container) return; - - const updateChartReady = () => { - const nextReady = - container.clientWidth > 0 && container.clientHeight > 0; - setOracleHistoryChartReady((prevReady) => - prevReady === nextReady ? prevReady : nextReady, - ); - }; - - updateChartReady(); - - const resizeObserver = - typeof ResizeObserver !== "undefined" - ? new ResizeObserver(updateChartReady) - : null; - resizeObserver?.observe(container); - - window.addEventListener("resize", updateChartReady); - return () => { - resizeObserver?.disconnect(); - window.removeEventListener("resize", updateChartReady); - }; - }, []); - React.useEffect(() => { if (E2E_MODEL_ENTRY) { setData({ @@ -1802,80 +1778,81 @@ export function ModelsMarketView({
{copy.waitingForSnapshots}
- ) : oracleHistoryChartReady ? ( - - - - - formatUsd(value, locale, 0) - } - /> - { - if (!active || !payload?.length) return null; - const point = payload[0] - ?.payload as OracleHistoryPoint; - return ( -
- {formatUsd(point.spotIndex, locale)} - - {copy.skill}{" "} - {formatLocaleNumber( - point.conservativeSkill, - locale, - { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }, - )}{" "} - · μ{" "} - {formatLocaleNumber(point.mu, locale, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}{" "} - · σ{" "} - {formatLocaleNumber(point.sigma, locale, { + ) : oracleHistoryChartSize ? ( + + + + formatUsd(value, locale, 0) + } + /> + { + if (!active || !payload?.length) return null; + const point = payload[0]?.payload as OracleHistoryPoint; + return ( +
+ {formatUsd(point.spotIndex, locale)} + + {copy.skill}{" "} + {formatLocaleNumber( + point.conservativeSkill, + locale, + { minimumFractionDigits: 2, maximumFractionDigits: 2, - })} - -
- ); - }} + }, + )}{" "} + · μ{" "} + {formatLocaleNumber(point.mu, locale, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}{" "} + · σ{" "} + {formatLocaleNumber(point.sigma, locale, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +
+
+ ); + }} + /> + {selectedMarket?.spotIndex && ( + - {selectedMarket?.spotIndex && ( - - )} - -
-
+ )} + + ) : null} diff --git a/packages/hyperbet-ui/src/components/PredictionMarketPanel.tsx b/packages/hyperbet-ui/src/components/PredictionMarketPanel.tsx index e38a4202..ce670665 100644 --- a/packages/hyperbet-ui/src/components/PredictionMarketPanel.tsx +++ b/packages/hyperbet-ui/src/components/PredictionMarketPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, ReactNode } from "react"; +import { useRef, useState, ReactNode } from "react"; import { getUiCopy, resolveUiLocale, type UiLocale } from "@hyperbet/ui/i18n"; import { LineChart, @@ -6,9 +6,9 @@ import { XAxis, YAxis, Tooltip, - ResponsiveContainer, ReferenceLine, } from "recharts"; +import { useMeasuredContentBox } from "../lib/useMeasuredContentBox"; import { OrderBook, type OrderLevel } from "./OrderBook"; import { RecentTrades, type Trade } from "./RecentTrades"; @@ -89,7 +89,7 @@ export function PredictionMarketPanel({ const copy = getUiCopy(resolvedLocale); const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy"); const chartContainerRef = useRef(null); - const [chartReady, setChartReady] = useState(false); + const chartSize = useMeasuredContentBox(chartContainerRef, true, 2); const yesSelected = side === "YES"; const noSelected = side === "NO"; @@ -121,32 +121,6 @@ export function PredictionMarketPanel({ ? "linear-gradient(90deg, var(--hm-buy-soft, rgba(34,197,94,0.2)), var(--hm-buy), var(--hm-buy-soft, rgba(34,197,94,0.2)))" : "linear-gradient(90deg, var(--hm-buy-soft, rgba(34,197,94,0.2)), var(--hm-buy), var(--hm-buy-soft, rgba(34,197,94,0.2)))"; - useEffect(() => { - const container = chartContainerRef.current; - if (!container) return; - - const updateChartReady = () => { - const nextReady = - container.clientWidth > 0 && container.clientHeight > 0; - setChartReady((prevReady) => - prevReady === nextReady ? prevReady : nextReady, - ); - }; - - updateChartReady(); - - const resizeObserver = - typeof ResizeObserver !== "undefined" - ? new ResizeObserver(updateChartReady) - : null; - resizeObserver?.observe(container); - - window.addEventListener("resize", updateChartReady); - return () => { - resizeObserver?.disconnect(); - window.removeEventListener("resize", updateChartReady); - }; - }, []); const C_YES_BAR_SHADOW = compact ? "0 0 8px var(--hm-buy-glow-strong, rgba(34,197,94,0.5))" : "0 0 8px var(--hm-buy-glow-strong, rgba(34,197,94,0.5))"; @@ -930,73 +904,75 @@ export function PredictionMarketPanel({ zIndex: 1, }} > - {chartReady ? ( - - - - - - - - - - - { - if (active && payload && payload.length) { - return ( -
- - {payload[0].value}% - -
- ); - } - return null; - }} - /> - - -
-
+ {chartSize ? ( + + + + + + + + + + { + if (active && payload && payload.length) { + return ( +
+ + {payload[0].value}% + +
+ ); + } + return null; + }} + /> + + +
) : null} diff --git a/packages/hyperbet-ui/src/lib/useMeasuredContentBox.ts b/packages/hyperbet-ui/src/lib/useMeasuredContentBox.ts new file mode 100644 index 00000000..0fd23e4e --- /dev/null +++ b/packages/hyperbet-ui/src/lib/useMeasuredContentBox.ts @@ -0,0 +1,94 @@ +import { type RefObject, useEffect, useState } from "react"; + +type ContentBoxSize = { + width: number; + height: number; +}; + +function parseCssPixels(value: string): number { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function measureContentBox(element: HTMLElement): ContentBoxSize | null { + const style = window.getComputedStyle(element); + const horizontalPadding = + parseCssPixels(style.paddingLeft) + parseCssPixels(style.paddingRight); + const verticalPadding = + parseCssPixels(style.paddingTop) + parseCssPixels(style.paddingBottom); + const width = Math.max(0, element.clientWidth - horizontalPadding); + const height = Math.max(0, element.clientHeight - verticalPadding); + + if (width <= 0 || height <= 0) { + return null; + } + + return { width, height }; +} + +export function useMeasuredContentBox( + ref: RefObject, + enabled = true, + settleFrames = 1, +): ContentBoxSize | null { + const [size, setSize] = useState(null); + + useEffect(() => { + if (!enabled) { + setSize(null); + return; + } + + const element = ref.current; + if (!element || typeof window === "undefined") { + setSize(null); + return; + } + + let firstFrame = 0; + let secondFrame = 0; + + const update = () => { + setSize((current) => { + const next = measureContentBox(element); + if (!current || !next) { + return next; + } + return current.width === next.width && current.height === next.height + ? current + : next; + }); + }; + + const scheduleUpdate = () => { + window.cancelAnimationFrame(firstFrame); + window.cancelAnimationFrame(secondFrame); + firstFrame = window.requestAnimationFrame(() => { + if (settleFrames > 1) { + secondFrame = window.requestAnimationFrame(update); + return; + } + update(); + }); + }; + + scheduleUpdate(); + + const resizeObserver = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(scheduleUpdate) + : null; + resizeObserver?.observe(element); + + window.addEventListener("resize", scheduleUpdate); + + return () => { + window.cancelAnimationFrame(firstFrame); + window.cancelAnimationFrame(secondFrame); + resizeObserver?.disconnect(); + window.removeEventListener("resize", scheduleUpdate); + }; + }, [enabled, ref, settleFrames]); + + return size; +} From 47ced58cc8db2c5f3ac89dd0664390979831d7b8 Mon Sep 17 00:00:00 2001 From: rndrntwrk <180591682+rndrntwrk@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:20:49 -0500 Subject: [PATCH 010/161] feat: overhaul personal staging stream delivery --- docs/hyperbet-production-deploy.md | 301 +--- .../hyperscapes-local-pm-integration.md | 298 +--- docs/runbooks/stale-oracle-or-stale-stream.md | 83 +- docs/system-design-alignment.md | 351 +---- .../hyperbet-avax/app/public/hls-player.html | 465 ++++++ .../hyperbet-bsc/app/public/hls-player.html | 465 ++++++ packages/hyperbet-bsc/app/src/App.tsx | 309 ++-- packages/hyperbet-bsc/app/src/lib/config.ts | 36 +- packages/hyperbet-bsc/keeper/src/betSync.ts | 596 ++++++++ packages/hyperbet-bsc/keeper/src/service.ts | 1312 ++++++++++++++++- .../hyperbet-evm/app/public/hls-player.html | 465 ++++++ .../app/public/hls-player.html | 465 ++++++ packages/hyperbet-solana/app/src/App.tsx | 296 ++-- .../hyperbet-solana/app/src/lib/config.ts | 9 +- .../hyperbet-solana/keeper/src/betSync.ts | 596 ++++++++ .../hyperbet-solana/keeper/src/service.ts | 1262 +++++++++++++++- packages/hyperbet-ui/package.json | 2 + .../src/components/StreamPlayer.tsx | 972 +++++++++--- packages/hyperbet-ui/src/lib/config.ts | 53 +- packages/hyperbet-ui/src/lib/streamSession.ts | 207 +++ .../src/lib/useMeasuredContentBox.ts | 94 ++ packages/hyperbet-ui/src/spectator/index.ts | 5 + packages/hyperbet-ui/src/spectator/types.ts | 68 + .../spectator/useCanonicalStreamSession.ts | 475 ++++++ .../src/spectator/useStreamingState.ts | 206 +-- packages/hyperbet-ui/src/styles.css | 9 + .../hyperbet-ui/tests/streamSession.test.ts | 139 ++ scripts/testnet-acceptance-env.ts | 2 +- 28 files changed, 8002 insertions(+), 1539 deletions(-) create mode 100644 packages/hyperbet-avax/app/public/hls-player.html create mode 100644 packages/hyperbet-bsc/app/public/hls-player.html create mode 100644 packages/hyperbet-bsc/keeper/src/betSync.ts create mode 100644 packages/hyperbet-evm/app/public/hls-player.html create mode 100644 packages/hyperbet-solana/app/public/hls-player.html create mode 100644 packages/hyperbet-solana/keeper/src/betSync.ts create mode 100644 packages/hyperbet-ui/src/lib/streamSession.ts create mode 100644 packages/hyperbet-ui/src/lib/useMeasuredContentBox.ts create mode 100644 packages/hyperbet-ui/src/spectator/useCanonicalStreamSession.ts create mode 100644 packages/hyperbet-ui/tests/streamSession.test.ts diff --git a/docs/hyperbet-production-deploy.md b/docs/hyperbet-production-deploy.md index fb630d87..20f64b37 100644 --- a/docs/hyperbet-production-deploy.md +++ b/docs/hyperbet-production-deploy.md @@ -1,274 +1,61 @@ -# Hyperbet Production Deploy (Cloudflare + Railway) +# Hyperbet Deploy Topology -> **TL;DR:** This is the production and staged topology for the phase-1 launch product. Launch-blocking chains are `Solana`, `BSC`, and `AVAX`; `Base` remains a non-blocking add-chain lane. The deployment topology is in place, but as of 2026-03-25 real staged proof and soak remain blocked because GitHub does not yet have a provisioned `staging` environment or the required `HYPERBET_*_STAGING_*` vars and secrets. +This document defines the intended deploy model after the streaming overhaul. -This is the recommended production topology for the Hyperbet stack in this repo. +## Product Topology -Operator runbooks are in [docs/runbooks/README.md](runbooks/README.md). +- Frontends: Cloudflare Pages per chain surface +- Keepers: Railway per chain surface +- Duel and stream truth: Hyperscapes +- Viewer delivery target: Cloudflare Stream LL-HLS +- Fallback delivery: self-hosted HLS from the Hyperscapes GPU host -- Primary frontend (`packages/hyperbet-solana/app`): Cloudflare Pages (`hyperbet.win`) -- Secondary frontends (`packages/hyperbet-bsc/app`, `packages/hyperbet-avax/app`): dedicated Pages project or subdomain per chain -- Primary betting API (`packages/hyperbet-solana/keeper`): Railway -- Secondary betting APIs (`packages/hyperbet-bsc/keeper`, `packages/hyperbet-avax/keeper`): dedicated Railway services if you split by chain -- Live duel/stream source (`packages/server` or Vast duel stack): separate upstream that the keeper polls -- DDoS/WAF/edge cache: Cloudflare proxy in front of the betting API -- Contracts/state: Solana + EVM (configured by env vars below, proxied server-side) +Hyperbet frontends and keepers should not point directly at an arbitrary local +HLS path unless they are in fallback or diagnostics mode. -Production rollout is still blocked until canonical launch-chain deployment -truth exists in the shared chain registry for `Solana`, `BSC`, and `AVAX`, -staged proof artifacts are captured for the target environment, and the real -governance and operator wallets are provisioned. +## Delivery Inputs Hyperbet Consumes -## Staging Rail +Keepers and frontends should rely on: -The repo also supports a manual staging rail for Solana, BSC, and AVAX without -changing the production topology: +- canonical session playback URL +- canonical `delivery.mode` +- canonical `delivery.provider` +- canonical renderer metrics -- staged Solana Pages + staged Solana keeper -- staged BSC Pages + staged BSC keeper -- staged AVAX Pages + staged AVAX keeper -- external staged duel/stream source +Expected envs on the keeper side: -Manual staging deploys use the same workflows as production through -`workflow_dispatch`: +- `STREAM_DELIVERY_MODE=self_hls|external_hls` +- `STREAM_DELIVERY_PROVIDER` +- `STREAM_PLAYBACK_HLS_URL` +- `STREAM_PLAYBACK_LLHLS_URL` +- `STREAM_RENDERER_HEALTH_URL` +- `STREAM_RENDERER_HEALTH_BEARER_TOKEN` -- `Deploy Hyperbet Solana Pages` -- `Deploy Hyperbet Solana Keeper` -- `Deploy Hyperbet BSC Pages` -- `Deploy Hyperbet BSC Keeper` -- `Deploy Hyperbet AVAX Pages` -- `Deploy Hyperbet AVAX Keeper` +## Frontend Playback Policy -Select `environment=staging` when dispatching the relevant workflow. +All player surfaces should use the same low-latency HLS tuning: -Current audited GitHub state: +- `lowLatencyMode: true` +- `liveSyncDurationCount: 2` +- `liveMaxLatencyDurationCount: 4` +- `maxBufferLength: 6` +- `maxMaxBufferLength: 12` +- `liveBackBufferLength: 10` +- `maxLiveSyncPlaybackRate: 1.5` -- repo-level deploy and testnet secrets exist -- no GitHub `staging` environment exists yet -- no `HYPERBET_*_STAGING_*` vars or secrets are provisioned yet -- BSC/AVAX auto-deploy flags can now be split per surface with `HYPERBET_*_PAGES_DEPLOY_ENABLED` and `HYPERBET_*_KEEPER_DEPLOY_ENABLED`; the older shared `HYPERBET_*_DEPLOY_ENABLED` vars remain as a fallback +## Staging And Production Rule -That means the code path is ready, but staged proof and staged soak remain -operationally blocked until staging is provisioned. +- test new stream behavior on integration branches first +- push real runtime fixes back to the owning scoped branches after proof +- keep staging-only glue and personal-staging overrides out of canonical PRs -Required staging vars are: +## Verification Gates -- `HYPERBET_SOLANA_PAGES_STAGING_PROJECT_NAME` -- `HYPERBET_SOLANA_PAGES_STAGING_URL` -- `HYPERBET_SOLANA_KEEPER_STAGING_URL` -- `HYPERBET_SOLANA_KEEPER_STAGING_WS_URL` -- `HYPERBET_SOLANA_RAILWAY_STAGING_PROJECT_ID` -- `HYPERBET_SOLANA_RAILWAY_STAGING_ENVIRONMENT_ID` -- `HYPERBET_SOLANA_RAILWAY_STAGING_KEEPER_SERVICE_ID` -- `HYPERBET_BSC_PAGES_STAGING_PROJECT_NAME` -- `HYPERBET_BSC_PAGES_STAGING_URL` -- `HYPERBET_BSC_KEEPER_STAGING_URL` -- `HYPERBET_BSC_KEEPER_STAGING_WS_URL` -- `HYPERBET_BSC_RAILWAY_STAGING_PROJECT_ID` -- `HYPERBET_BSC_RAILWAY_STAGING_ENVIRONMENT_ID` -- `HYPERBET_BSC_RAILWAY_STAGING_KEEPER_SERVICE_ID` -- `HYPERBET_AVAX_PAGES_STAGING_PROJECT_NAME` -- `HYPERBET_AVAX_PAGES_STAGING_URL` -- `HYPERBET_AVAX_KEEPER_STAGING_URL` -- `HYPERBET_AVAX_KEEPER_STAGING_WS_URL` -- `HYPERBET_AVAX_RAILWAY_STAGING_PROJECT_ID` -- `HYPERBET_AVAX_RAILWAY_STAGING_ENVIRONMENT_ID` -- `HYPERBET_AVAX_RAILWAY_STAGING_KEEPER_SERVICE_ID` -- `HYPERBET_AVAX_STAGING_CHAIN_ID` -- `HYPERBET_AVAX_STAGING_GOLD_CLOB_ADDRESS` +Before promoting a rollout: -Required staged proof vars/secrets used by the launch-scope proof rail: - -- `HYPERBET_STAGED_PROOF_DUEL_ID` -- `HYPERBET_STAGED_PROOF_DUEL_KEY` -- `HYPERBET_SOLANA_STAGING_CLUSTER` -- `HYPERBET_SOLANA_STAGING_RPC_URL` -- `HYPERBET_SOLANA_STAGING_GOLD_CLOB_PROGRAM_ID` -- `HYPERBET_SOLANA_STAGING_GOLD_AMM_PROGRAM_ID` -- `HYPERBET_SOLANA_STAGING_GOLD_PERPS_PROGRAM_ID` -- `HYPERBET_SOLANA_STAGING_STREAM_PUBLISH_KEY` -- `HYPERBET_SOLANA_STAGING_ORACLE_AUTHORITY_KEYPAIR` -- `HYPERBET_SOLANA_STAGING_CANARY_KEYPAIR` -- `HYPERBET_BSC_STAGING_RPC_URL` -- `HYPERBET_BSC_STAGING_REPORTER_PRIVATE_KEY` -- `HYPERBET_BSC_STAGING_CANARY_PRIVATE_KEY` -- `HYPERBET_BSC_STAGING_ADMIN_PRIVATE_KEY` -- `HYPERBET_BSC_STAGING_MARKET_OPERATOR_PRIVATE_KEY` -- `HYPERBET_BSC_STAGING_DUEL_ORACLE_ADDRESS` -- `HYPERBET_BSC_STAGING_GOLD_CLOB_ADDRESS` -- `HYPERBET_BSC_STAGING_GOLD_AMM_ROUTER_ADDRESS` -- `HYPERBET_BSC_STAGING_MUSD_TOKEN_ADDRESS` -- `HYPERBET_BSC_STAGING_GOLD_TOKEN_ADDRESS` -- `HYPERBET_BSC_STAGING_SKILL_ORACLE_ADDRESS` -- `HYPERBET_BSC_STAGING_PERP_ENGINE_ADDRESS` -- `HYPERBET_BSC_STAGING_STREAM_PUBLISH_KEY` -- `HYPERBET_AVAX_STAGING_RPC_URL` -- `HYPERBET_AVAX_STAGING_REPORTER_PRIVATE_KEY` -- `HYPERBET_AVAX_STAGING_CANARY_PRIVATE_KEY` -- `HYPERBET_AVAX_STAGING_ADMIN_PRIVATE_KEY` -- `HYPERBET_AVAX_STAGING_MARKET_OPERATOR_PRIVATE_KEY` -- `HYPERBET_AVAX_STAGING_DUEL_ORACLE_ADDRESS` -- `HYPERBET_AVAX_STAGING_GOLD_CLOB_ADDRESS` -- `HYPERBET_AVAX_STAGING_GOLD_AMM_ROUTER_ADDRESS` -- `HYPERBET_AVAX_STAGING_MUSD_TOKEN_ADDRESS` -- `HYPERBET_AVAX_STAGING_GOLD_TOKEN_ADDRESS` -- `HYPERBET_AVAX_STAGING_SKILL_ORACLE_ADDRESS` -- `HYPERBET_AVAX_STAGING_PERP_ENGINE_ADDRESS` -- `HYPERBET_AVAX_STAGING_STREAM_PUBLISH_KEY` -- `HYPERBET_AVAX_RAILWAY_STAGING_PROJECT_ID` -- `HYPERBET_AVAX_RAILWAY_STAGING_ENVIRONMENT_ID` -- `HYPERBET_AVAX_RAILWAY_STAGING_KEEPER_SERVICE_ID` - -AVAX rollout remains blocked until canonical deployment truth exists in the shared chain registry and the effective AVAX wallet/signer set is in place. The staging/prod rail is present so proof and release packaging can use one consistent contract once those addresses are committed. - -## 1) Deploy the keeper to Railway - -From repo root, deploy the keeper service path: - -```bash -railway up packages/hyperbet-solana --path-as-root -s gold-betting-keeper -``` - -Use `packages/hyperbet-solana/railway.json`. - -Set these Railway variables at minimum: - -- `NODE_ENV=production` -- `PORT=8080` (or let Railway inject its port if you proxy through the service domain) -- `STREAM_STATE_SOURCE_URL=https://your-stream-source.example/api/streaming/state` -- `STREAM_STATE_SOURCE_BEARER_TOKEN=...` if the upstream streaming state is protected -- `ARENA_EXTERNAL_BET_WRITE_KEY=...` -- `STREAM_PUBLISH_KEY=...` if you use `/api/streaming/state/publish` -- `SOLANA_CLUSTER=mainnet-beta` -- `SOLANA_RPC_URL=...` -- `BSC_RPC_URL=...` -- `BSC_GOLD_CLOB_ADDRESS=...` -- `BASE_RPC_URL=...` -- `BASE_GOLD_CLOB_ADDRESS=...` -- `AVAX_RPC_URL=...` for AVAX keeper/runtime support after canonical registry values exist -- `BIRDEYE_API_KEY=...` if token-price proxying is enabled - -Persistence: - -- The keeper defaults to a local SQLite file (`KEEPER_DB_PATH=./keeper.sqlite`). -- On Railway that file is ephemeral unless you attach a persistent volume or move the keeper state to an external database. -- Do not treat points history, referrals, or oracle history as durable unless persistence is configured explicitly. - -Notes: - -- The keeper serves the Pages app's read/write betting APIs. It is not the same process as the Hyperscape duel server. -- The keeper also proxies Solana and EVM JSON-RPC for the public app. Keep provider-keyed RPC URLs on Railway, not in Cloudflare Pages build vars. -- The keeper now keeps a short in-memory cache for read-only RPC and Birdeye proxy traffic. Tune it with `RPC_PROXY_CACHE_MAX_ENTRIES`, `RPC_PROXY_CACHE_MAX_PAYLOAD_BYTES`, and `BIRDEYE_PRICE_CACHE_TTL_MS` if needed. -- The keeper will return boot fallback duel data until `STREAM_STATE_SOURCE_URL` is set and the upstream duel server responds. -- The autonomous keeper bot also needs a funded signer wallet on Solana to create/resolve markets in production. - -## 2) Deploy the live duel server / stream source - -This can be the Railway `hyperscape` service or the Vast.ai duel stack. It must expose: - -- `/api/streaming/state` -- `/api/streaming/duel-context` -- `/api/streaming/rtmp/status` -- `/live/stream.m3u8` - -If you run the Vast.ai stack, verify it before pointing the keeper at it: - -```bash -./scripts/check-streaming-status.sh http://127.0.0.1:5555 -``` - -## 3) Put the betting API behind Cloudflare - -1. Create `api.yourdomain.com` in Cloudflare DNS and point it to the keeper Railway target. -2. Enable Cloudflare proxy (orange cloud) for `api.yourdomain.com`. -3. Add WAF rate-limit rules: -- `POST /api/arena/bet/record-external` -- `POST /api/arena/deposit/ingest` -- `/api/arena/points/*` -4. Keep the direct Railway URL private if you introduce a public API domain. - -## 4) Deploy frontend to Cloudflare Pages - -Project root: - -- `packages/hyperbet-solana/app` - -Build/output: - -- Build command: `bun install && bun run build --mode mainnet-beta` -- Output directory: `dist` - -Frontend env vars (Cloudflare Pages): - -- `VITE_GAME_API_URL=https://api.yourdomain.com` -- `VITE_GAME_WS_URL=wss://api.yourdomain.com/ws` if the keeper exposes websocket features you use -- `VITE_SOLANA_CLUSTER=mainnet-beta` (or testnet/devnet) -- `VITE_USE_GAME_RPC_PROXY=true` -- `VITE_USE_GAME_EVM_RPC_PROXY=true` -- `VITE_BSC_GOLD_CLOB_ADDRESS` / `VITE_BASE_GOLD_CLOB_ADDRESS` -- `VITE_BSC_GOLD_TOKEN_ADDRESS` / `VITE_BASE_GOLD_TOKEN_ADDRESS` -- `VITE_STREAM_SOURCES=https://your-hls-or-embed-source,...` - -Do not set provider-keyed values in any `VITE_*RPC_URL` variable for production builds. The betting app build fails intentionally if a public RPC URL looks like a Helius / Alchemy / Infura / QuickNode / dRPC secret endpoint. - -Do not treat `packages/hyperbet-avax/deployments/contracts.json` as production deployment truth. The shared chain registry is the canonical production source, and AVAX rollout must stay blocked until that registry is populated with real addresses. - -Cloudflare Pages headers/SPA rules are already added in: - -- `packages/hyperbet-solana/app/public/_headers` -- `packages/hyperbet-solana/app/public/_redirects` - -Deployment metadata: - -- `build-info.json` is emitted into `dist/` on every build and should be served with `Cache-Control: no-store`. -## 5) Verify production - -Health: - -- `https://api.yourdomain.com/status` -- `https://bet.yourdomain.com` -- `https://api.yourdomain.com/api/streaming/state` -- `https://api.yourdomain.com/api/streaming/duel-context` -- `https://api.yourdomain.com/api/perps/markets` -- `https://api.yourdomain.com/api/proxy/evm/rpc?chain=bsc` (POST JSON-RPC smoke test) -- `https://bet.yourdomain.com/build-info.json` - -Repo-backed checks from repo root: - -```bash -./scripts/check-streaming-status.sh https://your-stream-source.example -bun run --cwd packages/hyperbet-solana build:mainnet -``` - -## 6) Run staged live proof - -Use the manual `Staged Live Proof` workflow or the repo wrapper: - -```bash -bun run staged:proof -- --mode=read-only --target=all -bun run staged:proof -- --mode=canary-write --target=solana -bun run staged:proof -- --mode=canary-write --target=bsc -bun run staged:proof -- --mode=canary-write --target=avax -``` - -The proof wrapper captures: - -- Pages `build-info.json` -- keeper `/status` -- `/api/arena/prediction-markets/active` -- `/api/keeper/bot-health` -- stream-state and duel-context payloads -- Solana and BSC proxy proof -- Solana, BSC, and AVAX canary tx hashes/signatures when `mode=canary-write` -- `verify:chains` output -- AVAX staging env-audit output - -This is a manual operator proof rail. It should not be treated as complete -until a real staged run passes end to end and the artifacts are reviewed. - -## 7) Security notes - -- Do not expose `ARENA_EXTERNAL_BET_WRITE_KEY` in public frontend env vars. -- Do not ship provider-keyed RPC URLs in public frontend env vars. Keep them on Railway and let the keeper proxy them. -- Rotate all secrets before production if they were ever committed/shared. -- Keep `DISABLE_RATE_LIMIT` unset in production. +1. keepers expose `rendererHealth`, `rendererMetrics`, and `delivery` +2. Solana and EVM surfaces render the same active duel +3. live-edge latency stays within target bounds +4. no sustained `Playback stalled -> Rebuilding stream` loop occurs during soak +5. stale-stream recovery can identify whether the issue was source, delivery, or + player drift diff --git a/docs/runbooks/hyperscapes-local-pm-integration.md b/docs/runbooks/hyperscapes-local-pm-integration.md index 029420f5..a6c03ed2 100644 --- a/docs/runbooks/hyperscapes-local-pm-integration.md +++ b/docs/runbooks/hyperscapes-local-pm-integration.md @@ -1,276 +1,72 @@ # Hyperscapes Local PM Integration -> **TL;DR:** This is the fastest local debug lane for the real `Hyperscapes -> Hyperbet` integration. It uses local Hyperscapes plus local keeper and UI, and it can optionally drive local BSC and AVAX write paths with anvil-backed deployments. It is not the final signoff lane; release signoff still comes from staged proof and soak. +This is the local and personal-staging integration model after the streaming +overhaul. -This is the local integration path for prediction markets against the real -Hyperscapes duel stack. It does **not** seed synthetic markets and it does -**not** treat the game server as the Hyperbet API. +## Boundary -For this local runner, skipping Hyperscapes local MUD chain bootstrap is -intentional. Hyperbet consumes the duel telemetry API from Hyperscapes, not the -sibling repo's local anvil world. The runner also defaults the duel server to -development mode so local smoke does not fail on production-only JWT boot -requirements. +Hyperscapes provides: -## Architecture +- duel lifecycle +- renderer health +- renderer metrics +- delivery metadata -1. Hyperscapes is the duel event source. - - local game/server stack serves `GET /api/streaming/state` - - duel lifecycle comes from the running game +Hyperbet provides: -2. Hyperbet keeper is the bridge layer. - - polls the Hyperscapes streaming endpoint - - optionally runs the keeper bot internally - - exposes: - - `/status` - - `/api/streaming/state` - - `/api/streaming/state/events` - - `/api/arena/prediction-markets/active` +- keeper APIs +- betting surfaces +- chain interaction -3. Hyperbet UI points at the keeper service, not directly at the game server. - - `VITE_GAME_API_URL=http://127.0.0.1:8080` - - `VITE_GAME_WS_URL=ws://127.0.0.1:5555/ws` +The Hyperbet UI should point at the keeper for canonical session state and use +the playback URL carried in that session. It should not invent its own stream +selection rules beyond fallback behavior. -This split is required because the Hyperscapes server provides duel telemetry, -while the keeper service provides prediction-market state. +## Personal Staging -For local write-path smoke, the runner can boot fresh anvil-backed BSC and -AVAX deployments before starting the keeper. That path seeds fresh local EVM -contracts from the repo's local E2E setup scripts and is the preferred default -when you want visible market movement without depending on funded testnet -writer roles. +For `enoomian` personal staging: -## Scope +- Pages hosts `/stream` +- the GPU box renders and encodes +- Railway hosts the Hyperscapes API +- Hyperbet keepers poll Hyperscapes renderer health and session state +- Cloudflare Stream LL-HLS is the target viewer feed when configured +- self-hosted HLS remains available for smoke and rollback -The truthful local integrated lifecycle today is: +## Local Debug Rules -- open -- lock -- resolve +When the stream looks stale: -The current game client and generic EVM keeper path do **not** yet model local -duel cancellation as a first-class path. +1. check Hyperscapes capture status +2. check HLS manifest freshness +3. check the keeper canonical session +4. only then inspect the browser player -## Local Duel Preconditions +If the player alone drifted, rebuild the player. +If render or encode are stale, do not blame the player. -The local Hyperscapes server can be healthy and still remain in `IDLE` forever -if it has no duel agents available. That is the default state on a fresh local -database. - -The minimum viable local duel setup is: - -1. Start Hyperscapes in local integration mode: - -```bash -bash scripts/run-hyperscapes-pm-local.sh -``` - -2. If `/api/streaming/state` stays `IDLE`, create two local agent characters in - the sibling Hyperscapes repo: - -```bash -curl http://127.0.0.1:5555/api/characters/db \ - -X POST \ - -H 'content-type: application/json' \ - --data '{"accountId":"local-agent-account-a","name":"Local Agent A","isAgent":true}' - -curl http://127.0.0.1:5555/api/characters/db \ - -X POST \ - -H 'content-type: application/json' \ - --data '{"accountId":"local-agent-account-b","name":"Local Agent B","isAgent":true}' -``` - -3. Start those agents through the sibling repo's intended embedded-agent route: - -```bash -curl http://127.0.0.1:5555/api/agents//start -X POST -curl http://127.0.0.1:5555/api/agents//start -X POST -``` - -4. Confirm the duel has left `IDLE`: - -```bash -curl http://127.0.0.1:5555/api/streaming/state -curl http://127.0.0.1:8080/api/streaming/state -curl http://127.0.0.1:8080/api/arena/prediction-markets/active -``` - -If you already have model-provider keys configured in Hyperscapes, model-agent -spawning can also satisfy this requirement. The important point is simpler: the -streaming duel scheduler needs at least two available agents, or no duel cycle -will ever start. - -## Authority Model - -There are two separate wallet classes: - -1. Local smoke trader wallets - - used by the UI for order placement and claims - - private keys stay local under `keys/local-smoke/` - - public addresses are tracked in - [local-smoke-wallets.json](../release/evidence/local-smoke-wallets.json) - - GitHub can fund them through - [fund-local-smoke-wallets.yml](../../.github/workflows/fund-local-smoke-wallets.yml) - -2. Keeper writer wallets - - required for deployed testnet market automation - - EVM requires existing `REPORTER`, `MARKET_OPERATOR`, and `FINALIZER` - authority - - new post-deploy EVM writer wallets cannot be granted those roles because - the contracts freeze the governance surface after deployment - - therefore local duel -> deployed market automation requires the existing - writer keys to be available locally, or a separate remote writer service - -## Local Run - -From the Hyperbet repo root: +## Required Checks ```bash -bash scripts/run-hyperscapes-pm-local.sh +curl -fsSL "$HYPERSCAPES_URL/api/streaming/capture/status" | jq +curl -fsSL "$HYPERSCAPES_URL/api/streaming/state" | jq +curl -fsSL "$KEEPER_URL/api/streaming/state" | jq ``` -Repo location discovery: +Look for: -- the runner first honors `HYPERSCAPES_ROOT` if you set it explicitly -- otherwise it auto-detects common sibling locations such as: - - `/.worktrees/hyperscapes-stream-bet-sync` - - `/hyperscapes-stream-bet-sync` -- if your Hyperscapes checkout was moved elsewhere, set `HYPERSCAPES_ROOT=/abs/path/to/hyperscapes-stream-bet-sync` - -Defaults: - -- Hyperscapes game/server: `http://127.0.0.1:5555` -- Hyperbet keeper service: `http://127.0.0.1:8080` -- Hyperbet EVM app: `http://127.0.0.1:4179` -- EVM keeper chain scope: `bsc,avax` -- Hyperscapes chain bootstrap: skipped -- Hyperscapes node env: `development` -- interactive UI opening: disabled by default -- local monitor browser mode: headless by default -- screenshot viewport: `1280x720` - -The script: - -1. starts the sibling Hyperscapes duel stack with Hyperbet disabled there -2. when `PM_LOCAL_EVM_MODE=anvil` (the default), starts local anvil-backed BSC - and AVAX deployments by invoking the local E2E seeding scripts under - `packages/hyperbet-bsc/app/tests/e2e/` and - `packages/hyperbet-avax/app/tests/e2e/` -3. starts the local Hyperbet EVM keeper service against - `http://127.0.0.1:5555/api/streaming/state` -4. starts the local Hyperbet EVM app pointed at the keeper service -5. keeps UI opening off by default; manual browser launch is opt-in through - `OPEN_LOCAL_UI=true` -6. can start the PM soak follow monitor in the background, which records JSON - state plus paired UI screenshots into: - - `output/playwright/pm-soak//` - - screenshots are captured headlessly at `1280x720` unless overridden - -The default Hyperbet local page is now the normal betting surface: - -- `http://127.0.0.1:4179/` - -The `?debug=1` query is optional and only enables hidden E2E/operator controls. -It is not required for the real local betting flow or for headless stream -validation. - -Monitor and harness controls are explicit: -- `PM_E2E_MONITOR=true|false` toggles `scripts/pm-soak-monitor.ts` -- `PM_E2E_FULL_SOAK=true|false` toggles `scripts/soak-harness.ts` -- `PW_HEADLESS=1` keeps Playwright headless -- `PW_BROWSER_CHANNEL=chrome` is the preferred macOS local setting -- `PW_WEBGPU_ARGS="--enable-unsafe-webgpu"` keeps the local stream renderer on - the headless WebGPU lane -- `PM_SOAK_SCREENSHOT_WIDTH=1280` -- `PM_SOAK_SCREENSHOT_HEIGHT=720` - -`PM_E2E_MONITOR` defaults to the value of `CAPTURE_LOCAL_UI_FLOW`. - -When you enable `PM_E2E_FULL_SOAK=true`, the runner also starts -`scripts/soak-harness.ts` so the same local session executes an additional -PM-AMM + perps path against local BSC and active stream cycles. - -The PM soak monitor records key incidences automatically: - -- initial stack-up -- duel key change -- duel phase change -- first populated `markets[]` -- market-status changes -- final snapshot when the runner is stopped - -## Optional Local Env - -The runner auto-loads these gitignored local env files when present: - -- `/.env.stage-a.testnet.local` -- `/.env.testnet.local` -- `/packages/hyperbet-evm/keeper/.env` -- `/packages/hyperbet-evm/app/.env.local` - -Relevant writer env names: - -- `EVM_REPORTER_PRIVATE_KEY` -- `EVM_MARKET_OPERATOR_PRIVATE_KEY` -- `EVM_FINALIZER_PRIVATE_KEY` -- `TESTNET_REPORTER_PRIVATE_KEY` -- `TESTNET_MARKET_OPERATOR_PRIVATE_KEY` -- `TESTNET_FINALIZER_PRIVATE_KEY` -- fallback: `EVM_KEEPER_PRIVATE_KEY` - -When `PM_LOCAL_EVM_MODE=anvil`, the runner overrides those writer envs with the -default local-anvil admin key used by the local E2E EVM seeding scripts. The -local anvil ports default to `18545` for BSC and `18546` for AVAX, and the -runner derives the seeded oracle/CLOB addresses from each chain's local -`state.json`. - -If these are missing, the integrated stack still boots, but local duel events -cannot open and resolve deployed BSC/AVAX markets. In that case the runner -defaults `ENABLE_KEEPER_BOT=false` so the read path stays clean. - -Useful overrides: - -```bash -ENABLE_KEEPER_BOT=true bash scripts/run-hyperscapes-pm-local.sh -HYPERSCAPES_SKIP_CHAIN_SETUP=false bash scripts/run-hyperscapes-pm-local.sh -HYPERSCAPES_DUEL_NODE_ENV=production JWT_SECRET=... bash scripts/run-hyperscapes-pm-local.sh -OPEN_LOCAL_UI=false bash scripts/run-hyperscapes-pm-local.sh -CAPTURE_LOCAL_UI_FLOW=false bash scripts/run-hyperscapes-pm-local.sh -HYPERBET_UI_DEBUG=true bash scripts/run-hyperscapes-pm-local.sh -PM_E2E_MONITOR=true \ -PM_E2E_FULL_SOAK=true \ -PM_SOAK_LOCAL_DURATION_MIN=25 \ -PM_E2E_HARNESS_DURATION_MIN=25 \ -bash scripts/run-hyperscapes-pm-local.sh - -# Full E2E without local monitor/UI capture: -OPEN_LOCAL_UI=false \ -PM_E2E_MONITOR=false \ -PM_E2E_FULL_SOAK=true \ -PM_E2E_HARNESS_DURATION_MIN=25 \ -bash scripts/run-hyperscapes-pm-local.sh -``` +- `rendererHealth` +- `rendererMetrics` +- `delivery` +- `playback.url` -## Acceptance +## Operator Goal -Minimum healthy local integrated state: +Every local and personal-staging integration run should answer: -1. `GET http://127.0.0.1:5555/api/streaming/state` returns live duel state. -2. `GET http://127.0.0.1:8080/status` returns keeper health. -3. `GET http://127.0.0.1:8080/api/arena/prediction-markets/active` returns - prediction-market state. -4. `http://127.0.0.1:4179` loads with duel telemetry from Hyperscapes and - prediction markets from the keeper service. -5. With writer authority present locally, a live duel should drive: - - market open - - market lock - - oracle proposal/finalization - - claimable resolved state +- is the source rendering? +- is encode keeping up? +- is delivery fresh? +- is the player near the live edge? -6. If local writer authority is intentionally absent, the truthful expected - state is: - - live duel state visible in keeper and UI - - empty `markets[]` on `/api/arena/prediction-markets/active` - - `ENABLE_KEEPER_BOT=false` -7. The local evidence bundle contains paired Hyperscapes and Hyperbet UI - screenshots plus the backing keeper/game JSON for each captured incidence. +If those four answers are not separated, the incident data is incomplete. diff --git a/docs/runbooks/stale-oracle-or-stale-stream.md b/docs/runbooks/stale-oracle-or-stale-stream.md index 37c47dde..991c0ba1 100644 --- a/docs/runbooks/stale-oracle-or-stale-stream.md +++ b/docs/runbooks/stale-oracle-or-stale-stream.md @@ -1,51 +1,60 @@ # Stale Oracle Or Stale Stream -## Symptoms +## Goal -- duel phase or HP data stops updating -- quotes remain open around lock/resolve boundaries -- lifecycle status lags real duel outcomes +Separate four failure planes before taking action: + +1. source render +2. capture and encode +3. manifest and delivery freshness +4. player live-edge drift + +Do not treat all stale-stream incidents as one class of outage. ## Detection +Check Hyperscapes first: + ```bash -curl -fsSL "$KEEPER_URL/status" | jq '.parsers, .stream' -curl -fsSL "$KEEPER_URL/api/streaming/state" | jq -curl -fsSL "$KEEPER_URL/api/arena/prediction-markets/active" | jq -curl -fsSL "$KEEPER_URL/api/keeper/bot-health" | jq +curl -fsSL "$HYPERSCAPES_URL/api/streaming/capture/status" | jq +curl -fsSL "$HYPERSCAPES_URL/api/streaming/state" | jq +curl -fsSL "$HLS_URL" | head ``` -## Immediate Containment - -1. Halt quoting on affected chains. -2. Treat stale oracle or stale stream as higher priority than preserving uptime. -3. Do not resolve markets manually unless lifecycle state is confirmed from authoritative chain state. +Then check the keeper: -## Recovery Steps +```bash +curl -fsSL "$KEEPER_URL/api/streaming/state" | jq +curl -fsSL "$KEEPER_URL/status" | jq '.stream' +curl -fsSL "$KEEPER_URL/api/keeper/bot-health" | jq +``` -1. Confirm whether the failure is upstream stream data or chain/oracle freshness. -2. Restore stream/oracle input first. -3. Restart the keeper if it does not reconcile automatically. -4. Re-check canonical lifecycle state and bot-health freshness timestamps. -5. Re-enable quoting only after stale markers clear and lifecycle state matches chain state. +## Interpretation + +- `render_tick_stale` + - source page is not advancing +- `visual_change_stale` + - render loop is alive but duel visuals are not changing +- `capture_fps_low` + - capture path is overloaded +- `encoder_fps_low` + - encode path is overloaded or misconfigured +- `manifest_stale` + - delivery path is stale +- `player_drifted` + - viewer was too far behind the live edge + +## Recovery Order + +1. restore source render truth +2. restore capture and encode cadence +3. restore manifest and delivery freshness +4. force player rebuild only if the source and delivery are already healthy +5. restart the keeper only if it failed to ingest a now-healthy upstream state ## Success Criteria -- stream state updates again -- lifecycle records move out of stale or unknown state -- quote state remains disabled during stale input and resumes only after recovery - -## Escalation - -Escalate if: - -- stale input persists beyond the expected upstream recovery window -- markets remain open through lock or resolve boundaries -- settlement occurs from stale data - -## Evidence To Capture - -- latest stream payload -- `/status` parser and stream fields -- `/api/keeper/bot-health` freshness fields -- exact timestamps of last good update +- Hyperscapes capture status reports fresh `rendererHealth` +- capture metrics and encode metrics recover +- keepers expose the same delivery mode and renderer truth +- viewers return to live-edge playback without repeated rebuild loops diff --git a/docs/system-design-alignment.md b/docs/system-design-alignment.md index a22a345b..40bf9fed 100644 --- a/docs/system-design-alignment.md +++ b/docs/system-design-alignment.md @@ -1,315 +1,78 @@ # Hyperbet System Design Alignment -## Purpose +This is the current authoritative alignment between Hyperbet and Hyperscapes +for the streaming and betting stack. -This document aligns `hyperbet` and `hyperscape` around one platform model. -In the current sprint baseline, this is a **directional architecture document**, -not an override of the launch-hardening source of truth. +## Canonical Ownership -Today, the sprint branch still treats the following as authoritative: +Hyperscapes owns: -- `@hyperbet/chain-registry` for current chain/runtime truth -- the existing CI/deploy/proof rails for release hardening -- the existing BSC/AVAX wrappers and keepers for current launch surfaces +- duel lifecycle truth +- renderer truth +- delivery truth +- the canonical stream session consumed by downstream products -For the detailed keep/adapt/reject record on the imported -`hyperbet-evm-parity-sweep` direction, and the implemented local -standardization outcome, see -`docs/enoomian-evm-standardization-decisions.md`. +Hyperbet owns: -This document captures the intended convergence target while that migration is -still in progress: +- betting UX +- chain-specific keepers +- wallet interaction +- market-facing product surfaces -- `hyperscape` is the source of truth for duel lifecycle and stream state. -- `hyperbet` is the execution, market, and chain-facing product layer. -- Solana and EVM are execution adapters, not separate products. -- Chain-specific sites such as AVAX, BSC, and Base should be themed deployments of one canonical EVM runtime. +Hyperbet must consume Hyperscapes stream truth additively. It must not invent a +parallel renderer-health model. -The goal is to prevent design drift while the codebase transitions from mixed chain packages into a cleaner architecture. +## Streaming Contract -For the current Gold asset interpretation and the planned Gold architecture -track, see: +The canonical stream session now includes: -- `docs/protocol/gold-current-state.md` -- `docs/protocol/gold-architecture-spec-plan.md` +- `rendererHealth` +- `rendererMetrics` + - `captureFps` + - `encodeFps` + - `droppedFrames` + - `latestFrameAt` + - `latestRenderTickAt` + - `latestDuelStateTickAt` + - `latestVisualChangeAt` + - `visualChangeAgeMs` + - `hlsManifest.updatedAt` + - `hlsManifest.mediaSequence` +- `delivery` + - `mode` + - `provider` + - `playbackUrl` + - `hlsUrl` + - `llhlsUrl` -## Canonical Responsibilities +Hyperbet should use: -### Hyperscape +1. explicit Hyperscapes renderer truth first +2. HLS freshness as fallback only +3. local player telemetry to detect live-edge drift separately -- Owns duel lifecycle, stream state, simulation state, and canonical result data. -- Publishes oracle lifecycle events to chain targets. -- Exposes the HTTP and streaming APIs that Hyperbet consumes. +## Delivery Topology -### Hyperbet +- Pages hosts the public Hyperscapes stream page +- the GPU host renders and encodes +- Railway serves the keeper-facing and UI-facing stream session +- Cloudflare Stream LL-HLS is the target viewer-delivery path once enabled +- self-hosted HLS remains reachable for smoke and emergency fallback -- Owns prediction and perps market UX. -- Owns chain-facing keepers, market-making, wallet interactions, and deployments. -- Consumes Hyperscape duel truth instead of inventing a separate match state model. +## UI Rule -### Solana runtime +Every player surface should present the same operator-relevant dimensions: -- Lives in `packages/hyperbet-solana`. -- Owns Solana app, Solana keeper, Solana deployment tooling, Solana-specific wallet flow, and Solana program interactions. +- current live-edge latency +- stall count +- rebuild count +- last buffered fragment freshness +- current delivery mode -### EVM runtime +Degraded UI should distinguish: -- Lives in `packages/hyperbet-evm`. -- Owns the canonical EVM app shell and the canonicalized additive EVM keeper - direction on the local sprint-base standardization path. -- `packages/hyperbet-deployments` is subordinate to - `@hyperbet/chain-registry`, not a competing deployment authority. -- AVAX, BSC, and Base should continue converging toward wrapper shells over - this runtime, not separate architectures. +- source stale +- delivery stale +- player drifted -### Shared UI - -- Lives in `packages/hyperbet-ui`. -- Owns reusable components, chain-agnostic frontend logic, theme system, shared market panels, and shared spectator/streaming views. -- Theme should change presentation, not business logic. - -## Package Semantics - -### Hyperbet packages - -- `packages/hyperbet-ui` - - shared React UI and theme system -- `packages/hyperbet-evm` - - canonical EVM app shell - - canonicalized additive EVM keeper/backend surface on the local - sprint-base standardization path -- `packages/hyperbet-solana` - - canonical Solana app + keeper runtime -- `packages/hyperbet-avax` - - temporary EVM deployment wrapper -- `packages/hyperbet-bsc` - - temporary EVM deployment wrapper -- `packages/evm-contracts` - - canonical EVM contracts -- `packages/hyperbet-deployments` - - additive deployment materialization layer introduced for convergence work - - subordinate to the sprint branch's current chain-registry truth -- `packages/hyperbet-sdk` - - shared SDK surface for external consumers -- `packages/market-maker-bot` - - offchain quoting / liquidity automation - -### Hyperscape packages most relevant to Hyperbet - -- `../hyperscape/packages/server` - - duel lifecycle APIs, stream state, and oracle publishing integration -- `../hyperscape/packages/shared` - - shared simulation/runtime primitives -- `../hyperscape/packages/client` - - game client UI -- `../hyperscape/packages/duel-oracle-evm` - - canonical EVM oracle ABI/source -- `../hyperscape/packages/duel-oracle-solana` - - canonical Solana oracle program/IDL - -## Intended Runtime Flow - -1. Hyperscape server schedules and runs duel cycles. -2. Hyperscape emits canonical streaming state and duel lifecycle data. -3. Hyperbet keepers consume that duel lifecycle data. -4. Chain-specific keepers update oracle state and market state on their target chain. -5. Hyperbet frontend consumes: - - streaming state from keeper/Hyperscape APIs - - chain data from contracts or chain-aware keeper APIs -6. Market maker consumes the same canonical duel state and market state to quote liquidity. -7. Users interact with one shared Hyperbet product model through different chain runtimes. - -## Mermaid Diagram - -```mermaid -flowchart LR - subgraph HS[Hyperscape] - HSClient[Client] - HSServer[Server] - HSShared[Shared Runtime] - HSOracle[Duel Oracle Publisher] - end - - subgraph Shared[Shared Hyperbet Surface] - HUI[@hyperbet/ui] - HDeploy[@hyperbet/deployments] - HSDK[@hyperbet/sdk] - end - - subgraph HB[Hyperbet] - HEVM[hyperbet-evm] - HSOL[hyperbet-solana] - HAVAX[hyperbet-avax wrapper] - HBSC[hyperbet-bsc wrapper] - HMM[market-maker-bot] - HContracts[evm-contracts] - end - - HSShared --> HSServer - HSClient --> HSServer - HSServer --> HSOracle - - HSServer -->|stream state / duel lifecycle| HEVM - HSServer -->|stream state / duel lifecycle| HSOL - HSServer -->|state feed| HMM - - HSOracle -->|EVM oracle updates| HContracts - HSOracle -->|Solana oracle updates| HSOL - - HDeploy --> HEVM - HDeploy --> HSOL - HDeploy --> HAVAX - HDeploy --> HBSC - - HUI --> HEVM - HUI --> HSOL - HUI --> HAVAX - HUI --> HBSC - - HContracts --> HEVM - HSDK --> HEVM - HSDK --> HSOL - - HEVM --> HAVAX - HEVM --> HBSC -``` - -## Design Alignment Decisions - -### 1. Keep one product model - -Prediction and perps are product capabilities, not chain-specific products. - -- Duel lifecycle semantics must be identical across chain runtimes. -- Market metadata and result semantics must come from Hyperscape. -- Keepers should differ by execution adapter, not by business meaning. - -### 2. Keep one canonical EVM runtime - -`hyperbet-evm` is now the canonical EVM direction on the local sprint-base -standardization path, but deploy adoption and wrapper retirement still follow -the existing sprint operational model. - -- AVAX, Base, and BSC should be deployment presets plus theme wrappers. -- Chain wrappers should not own their own keeper/business logic long term. - -### 3. Keep one canonical Solana runtime - -`hyperbet-solana` remains the Solana-first runtime. - -- Solana-specific wallet, PDA, Anchor, and program logic should stay there. - -### 4. Converge toward one shared deployment manifest - -`@hyperbet/deployments` is the intended materialized manifest layer for: - -- contract/program addresses -- chain IDs -- operator/admin accounts -- margin token addresses - -It remains subordinate to the sprint branch's existing -`@hyperbet/chain-registry`, which continues to be authoritative for runtime and -release-hardening behavior. - -### 5. Keep one shared UI layer - -`@hyperbet/ui` should be the primary owner of: - -- visual language -- component system -- theme system -- chain-agnostic view logic - -Chain packages should only inject: - -- theme ID -- addresses and RPC config -- chain label/copy -- wallet provider/runtime setup - -## Audit Findings - -### Architecture - -1. The intended architecture is shared-product / chain-adapter, but the repo is still partly package-per-chain and partly product-per-chain. -2. `hyperbet-evm` is now the right direction, but AVAX and BSC wrappers still exist as semi-independent packages instead of thin deployment shells. -3. `market-maker-bot` is still cross-chain in an ad hoc way rather than consuming canonical runtime modules. - -### Keeper boundaries - -1. `hyperbet-evm/keeper` is directionally useful but is not yet accepted as the - canonical keeper baseline; see - `docs/enoomian-evm-standardization-decisions.md`. -2. `hyperbet-solana/keeper` remains the correct home for Solana-first automation. -3. Shared keeper logic has not yet been extracted into reusable modules, so semantic duplication risk remains. - -### UI and UX - -1. `@hyperbet/ui` is now correctly positioned as the shared UI surface. -2. The theme model is aligned with the desired deployment strategy. -3. The frontend still mixes shared market concepts with some chain-specific behavior and copy in package apps. -4. Prediction UX is closer to parity than perps UX. - -### Contract/runtime semantics - -1. EVM prediction flow is structurally integrated. -2. EVM perps are still not fully aligned with the intended economics and runtime behavior. -3. Deployment manifest centralization is correct, but not all consumers are yet manifest-first. - -### Cross-repo relationships - -1. Hyperscape runtime orchestration still points primarily at `hyperbet-solana` in the duel stack. -2. That means the repo orchestration scripts still encode an older “Solana-first sibling app” assumption. -3. The desired long-term architecture is “shared Hyperbet + chain adapters”, but the orchestration layer has not been updated to reflect that. - -## Current State Assessment - -### What is aligned - -- Canonical EVM runtime package now exists. -- Shared UI/theme direction is correct. -- Shared deployment-manifest package now exists as additive scaffolding. -- Solana and EVM runtime split is conceptually correct. - -### What is partially aligned - -- Keeper boundaries -- Chain wrappers -- Market-maker ownership -- Perps contract/runtime semantics -- Hyperscape orchestration scripts - -### What is not yet aligned - -- Thin-wrapper model for AVAX/BSC -- Fully shared keeper semantics -- Fully canonical market-maker integration model -- End-to-end EVM perps parity -- Repo-wide documentation of the target architecture - -## Recommended Next Steps - -### Phase 1: Runtime semantics - -1. Extract shared keeper domain logic into chain-agnostic modules. -2. Leave only execution adapters inside `hyperbet-evm/keeper` and `hyperbet-solana/keeper`. -3. Update Hyperscape duel-stack orchestration so Hyperbet runtime selection is explicit instead of implicitly Solana-first. - -### Phase 2: Package ownership - -1. Reduce `hyperbet-avax` and `hyperbet-bsc` toward thin wrappers over `hyperbet-evm`. -2. Keep all reusable UI in `@hyperbet/ui`. -3. Keep all EVM contract/address logic in `evm-contracts` + `hyperbet-deployments`. - -### Phase 3: Market architecture - -1. Recover intended perps semantics from repo evidence. -2. Align EVM perps contracts and keeper APIs to that spec. -3. Revalidate the market-maker against the canonical contract interfaces. - -### Phase 4: Product audit - -1. Audit UI and UX consistency across Solana and EVM. -2. Audit end-to-end game flow from Hyperscape duel lifecycle to market resolution. -3. Audit package semantics against this document before expanding further. +It should not collapse those conditions into generic stream failure. diff --git a/packages/hyperbet-avax/app/public/hls-player.html b/packages/hyperbet-avax/app/public/hls-player.html new file mode 100644 index 00000000..8485967e --- /dev/null +++ b/packages/hyperbet-avax/app/public/hls-player.html @@ -0,0 +1,465 @@ + + + + + + HLS Player + + + + +
+
+ + + + diff --git a/packages/hyperbet-bsc/app/public/hls-player.html b/packages/hyperbet-bsc/app/public/hls-player.html new file mode 100644 index 00000000..8485967e --- /dev/null +++ b/packages/hyperbet-bsc/app/public/hls-player.html @@ -0,0 +1,465 @@ + + + + + + HLS Player + + + + +
+
+ + + + diff --git a/packages/hyperbet-bsc/app/src/App.tsx b/packages/hyperbet-bsc/app/src/App.tsx index 5ecd4d3c..bf7c753c 100644 --- a/packages/hyperbet-bsc/app/src/App.tsx +++ b/packages/hyperbet-bsc/app/src/App.tsx @@ -30,12 +30,21 @@ import { captureInviteCodeFromLocation, getStoredInviteCode, } from "@hyperbet/ui/lib/invite"; -import { usePredictionMarketLifecycle } from "@hyperbet/ui/lib/predictionMarkets"; +import { + normalizePredictionMarketDuelKeyHex, + usePredictionMarketLifecycle, +} from "@hyperbet/ui/lib/predictionMarkets"; +import { + describeCanonicalRendererDegradedReason, + selectBetSurfaceStreamUrl, +} from "@hyperbet/ui/lib/streamSession"; import { StreamPlayer } from "@hyperbet/ui/components/StreamPlayer"; import { PointsDisplay } from "@hyperbet/ui/components/PointsDisplay"; import { useChain } from "./lib/ChainContext"; +import { useCanonicalStreamSession } from "@hyperbet/ui/spectator/useCanonicalStreamSession"; import { useStreamingState } from "@hyperbet/ui/spectator/useStreamingState"; import { useDuelContext } from "@hyperbet/ui/spectator/useDuelContext"; +import { useMeasuredContentBox } from "@hyperbet/ui/lib/useMeasuredContentBox"; import { useResizePanel, useIsMobile } from "@hyperbet/ui/lib/useResizePanel"; import { ResizeHandle } from "@hyperbet/ui/components/ResizeHandle"; import { @@ -44,7 +53,6 @@ import { XAxis, YAxis, Tooltip, - ResponsiveContainer, ReferenceLine, } from "recharts"; @@ -720,8 +728,16 @@ export function App() { >("leaderboard"); const appRootRef = useRef(null); const bettingDockInnerRef = useRef(null); + const chartContainerRef = useRef(null); + const chartSize = useMeasuredContentBox(chartContainerRef, !isMobile, 2); const { state: streamingState } = useStreamingState(); + const { + session: canonicalStreamSession, + playback: canonicalPlayback, + rendererHealth: canonicalRendererHealth, + authorityHealth: canonicalAuthorityHealth, + } = useCanonicalStreamSession(); const { context: duelContext } = useDuelContext(); const liveCycle = streamingState?.cycle ?? null; const lifecycleChainKey = @@ -736,7 +752,46 @@ export function App() { lifecycleChainKey, ); const streamSources = STREAM_URLS; - const activeStreamUrl = isE2eMode ? "" : (streamSources[streamSourceIndex] ?? ""); + const lifecycleDuelKey = normalizePredictionMarketDuelKeyHex( + lifecycleDuel?.duelKey ?? null, + ); + const { activeStreamUrl, preloadStreamUrl } = selectBetSurfaceStreamUrl({ + allowFallbackWhenSessionUnavailable: false, + authorityHealth: canonicalAuthorityHealth, + fallbackStreamIndex: streamSourceIndex, + fallbackStreamSources: streamSources, + isE2eMode, + lifecycleDuelId: lifecycleDuel?.duelId ?? null, + lifecycleDuelKey, + rendererReady: canonicalRendererHealth?.ready ?? null, + session: canonicalStreamSession, + }); + const mountedStreamUrl = activeStreamUrl || preloadStreamUrl; + const streamPlaceholderMessage = useMemo(() => { + if (streamSources.length === 0) { + return "Invalid live stream configuration. A tokenized Hyperscapes /stream URL is required."; + } + if (!canonicalStreamSession) { + return "Connecting to live session..."; + } + if (canonicalAuthorityHealth?.ready === false) { + return "Stream authority unavailable. Waiting for session state."; + } + if (canonicalRendererHealth?.ready === false) { + return describeCanonicalRendererDegradedReason( + canonicalRendererHealth.degradedReason, + copy.waitingForStream, + ); + } + return copy.waitingForStream; + }, [ + canonicalAuthorityHealth?.ready, + canonicalRendererHealth?.degradedReason, + canonicalRendererHealth?.ready, + canonicalStreamSession, + copy.waitingForStream, + streamSources.length, + ]); const handleLocaleChange = useCallback((nextLocale: UiLocale) => { setStoredUiLocale(nextLocale); @@ -1657,10 +1712,10 @@ export function App() { {/* Game Viewport */}
- {activeStreamUrl ? ( + {mountedStreamUrl ? ( <> -
- - {streamSources.length > 1 && ( + {activeStreamUrl ? ( +
- )} -
+ {streamSources.length > 1 && ( + + )} +
+ ) : null} + {!activeStreamUrl ? ( +
+
+ + {streamPlaceholderMessage} + +
+ ) : null} ) : (
- - {copy.waitingForStream} - + {streamPlaceholderMessage}
)}
{/* Odds Chart */} -
-
- - - -
-
- - {(effYesPercent / 100).toFixed(1)} - -
-
- - - { - const d = new Date(v); - return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; - }} - /> - `${v}%`} - /> - - active && payload?.length ? ( -
- {payload[0].value}% -
- ) : null - } - /> - - -
-
+ {!isMobile && ( +
+
+ + + +
+
+ + {(effYesPercent / 100).toFixed(1)} + +
+
+ {chartSize ? ( + + { + const d = new Date(v); + return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; + }} + /> + `${v}%`} + /> + + active && payload?.length ? ( +
+ {payload[0].value}% +
+ ) : null + } + /> + + +
+ ) : null} +
-
+ )}
0 ? trimmed : undefined; } +function readEnvStringOverride(name: string): string | undefined { + const rawValue = import.meta.env[name]; + if (typeof rawValue !== "string") return undefined; + return rawValue.trim(); +} + function readEnvNumber(name: string, fallback: number): number { const rawValue = readEnvString(name); if (!rawValue) return fallback; @@ -410,7 +417,7 @@ const defaultPrimaryStreamUrl = envStreamUrl ?? (suppressDefaultStreamFallback ? "" : baseEnvConfig.streamUrl); const resolvedStreamSources = (() => { if (envStreamSources.length > 0) { - return uniqueList(envStreamSources); + return sanitizeResolvedStreamSources(envStreamSources); } const envFallbackUrl = readEnvString("VITE_STREAM_FALLBACK_URL"); const fallbackUrl = @@ -418,8 +425,10 @@ const resolvedStreamSources = (() => { (defaultPrimaryStreamUrl && !suppressDefaultStreamFallback ? DEFAULT_STREAM_FALLBACK_URL : ""); - return uniqueList([defaultPrimaryStreamUrl, fallbackUrl ?? ""]).filter( - (value) => value.length > 0, + return sanitizeResolvedStreamSources( + uniqueList([defaultPrimaryStreamUrl, fallbackUrl ?? ""]).filter( + (value) => value.length > 0, + ), ); })(); const resolvedStreamUrl = resolvedStreamSources[0] ?? ""; @@ -495,29 +504,32 @@ export const CONFIG: EnvConfig = { readEnvString("VITE_HEADLESS_WALLETS") ?? baseEnvConfig.headlessWalletsJson, jupiterBaseUrl: readEnvString("VITE_JUPITER_BASE_URL") ?? baseEnvConfig.jupiterBaseUrl, - bscRpcUrl: readEnvString("VITE_BSC_RPC_URL") ?? baseEnvConfig.bscRpcUrl, + bscRpcUrl: + readEnvStringOverride("VITE_BSC_RPC_URL") ?? baseEnvConfig.bscRpcUrl, bscChainId: readEnvNumber("VITE_BSC_CHAIN_ID", baseEnvConfig.bscChainId), bscGoldClobAddress: - readEnvString("VITE_BSC_GOLD_CLOB_ADDRESS") ?? + readEnvStringOverride("VITE_BSC_GOLD_CLOB_ADDRESS") ?? baseEnvConfig.bscGoldClobAddress, bscGoldTokenAddress: - readEnvString("VITE_BSC_GOLD_TOKEN_ADDRESS") ?? + readEnvStringOverride("VITE_BSC_GOLD_TOKEN_ADDRESS") ?? baseEnvConfig.bscGoldTokenAddress, - baseRpcUrl: readEnvString("VITE_BASE_RPC_URL") ?? baseEnvConfig.baseRpcUrl, + baseRpcUrl: + readEnvStringOverride("VITE_BASE_RPC_URL") ?? baseEnvConfig.baseRpcUrl, baseChainId: readEnvNumber("VITE_BASE_CHAIN_ID", baseEnvConfig.baseChainId), baseGoldClobAddress: - readEnvString("VITE_BASE_GOLD_CLOB_ADDRESS") ?? + readEnvStringOverride("VITE_BASE_GOLD_CLOB_ADDRESS") ?? baseEnvConfig.baseGoldClobAddress, baseGoldTokenAddress: - readEnvString("VITE_BASE_GOLD_TOKEN_ADDRESS") ?? + readEnvStringOverride("VITE_BASE_GOLD_TOKEN_ADDRESS") ?? baseEnvConfig.baseGoldTokenAddress, - avaxRpcUrl: readEnvString("VITE_AVAX_RPC_URL") ?? baseEnvConfig.avaxRpcUrl, + avaxRpcUrl: + readEnvStringOverride("VITE_AVAX_RPC_URL") ?? baseEnvConfig.avaxRpcUrl, avaxChainId: readEnvNumber("VITE_AVAX_CHAIN_ID", baseEnvConfig.avaxChainId), avaxGoldClobAddress: - readEnvString("VITE_AVAX_GOLD_CLOB_ADDRESS") ?? + readEnvStringOverride("VITE_AVAX_GOLD_CLOB_ADDRESS") ?? baseEnvConfig.avaxGoldClobAddress, avaxGoldTokenAddress: - readEnvString("VITE_AVAX_GOLD_TOKEN_ADDRESS") ?? + readEnvStringOverride("VITE_AVAX_GOLD_TOKEN_ADDRESS") ?? baseEnvConfig.avaxGoldTokenAddress, walletConnectProjectId: readEnvString("VITE_WALLETCONNECT_PROJECT_ID") ?? diff --git a/packages/hyperbet-bsc/keeper/src/betSync.ts b/packages/hyperbet-bsc/keeper/src/betSync.ts new file mode 100644 index 00000000..a5d75604 --- /dev/null +++ b/packages/hyperbet-bsc/keeper/src/betSync.ts @@ -0,0 +1,596 @@ +import type { + PredictionMarketLifecycleRecord, + PredictionMarketLifecycleStatus, + PredictionMarketWinner, +} from "../../../hyperbet-chain-registry/src/index"; +import { + normalizePredictionMarketTimestamp, + normalizePredictionMarketWinner, +} from "../../../hyperbet-chain-registry/src/index"; + +type JsonRecord = Record; + +export type BetSyncRendererHealth = { + ready: boolean; + degradedReason: string | null; + updatedAt: number | null; +}; + +export type BetSyncHlsManifest = { + updatedAt: number | null; + mediaSequence: number | null; +}; + +export type BetSyncRendererMetrics = { + captureFps: number | null; + encodeFps: number | null; + droppedFrames: number | null; + renderTick: number | null; + duelStateTick: number | null; + latestFrameAt: number | null; + latestRenderTickAt: number | null; + latestDuelStateTickAt: number | null; + latestVisualChangeAt: number | null; + visualChangeAgeMs: number | null; + hlsManifest: BetSyncHlsManifest | null; +}; + +export type BetSyncDelivery = { + mode: "self_hls" | "external_hls"; + provider: string | null; + playbackUrl: string | null; + hlsUrl: string | null; + llhlsUrl: string | null; + ingestUrl: string | null; +}; + +export type BetSyncEvent = { + schemaVersion: number; + sourceEpoch: number; + seq: number; + emittedAt: number; + duelId: string | null; + duelKey: string | null; + phase: string | null; + phaseVersion: number | null; + betOpenTime: number | null; + betCloseTime: number | null; + fightStartTime: number | null; + duelEndTime: number | null; + winnerId: string | null; + winnerName: string | null; + winReason: string | null; + seed: string | null; + replayHash: string | null; + agent1: JsonRecord | null; + agent2: JsonRecord | null; + arenaPositions: JsonRecord | null; + leaderboard: JsonRecord[]; + cameraTarget: string | null; + rendererHealth: BetSyncRendererHealth | null; + rendererMetrics: BetSyncRendererMetrics | null; + delivery: BetSyncDelivery | null; +}; + +export type BetSyncBootstrapState = { + sourceEpoch: number; + latestSeq: number; + oldestReplaySeq: number | null; + latestEvent: BetSyncEvent | null; +}; + +export type StreamState = { + type: "STREAMING_STATE_UPDATE"; + cycle: JsonRecord; + leaderboard: JsonRecord[]; + cameraTarget: string | null; + seq: number; + emittedAt: number; +}; + +export type PredictionMarketsDuelSnapshot = { + duelKey: string | null; + duelId: string | null; + phase: string | null; + winner: PredictionMarketWinner; + betCloseTime: number | null; + agent1Name: string | null; + agent2Name: string | null; +}; + +export type PredictionMarketsSurface = { + duel: PredictionMarketsDuelSnapshot; + markets: PredictionMarketLifecycleRecord[]; + updatedAt: number | null; +}; + +export type PredictionMarketsOverviewResponse = { + updatedAt: number | null; + live: PredictionMarketsSurface | null; + recentSettlement: PredictionMarketsSurface | null; +}; + +export type BetSyncReplayMode = "bootstrap" | "replay" | "reset" | "live"; + +function asRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" ? (value as JsonRecord) : null; +} + +function asFiniteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function normalizeDuelKey(value: unknown): string | null { + const raw = asString(value); + if (!raw) return null; + const normalized = raw.replace(/^0x/i, "").trim().toLowerCase(); + return /^[0-9a-f]{64}$/.test(normalized) ? normalized : null; +} + +const PREDICTION_MARKET_STATUS_RANK: Record< + PredictionMarketLifecycleStatus, + number +> = { + UNKNOWN: -1, + PENDING: 0, + OPEN: 1, + LOCKED: 2, + PROPOSED: 3, + CHALLENGED: 4, + RESOLVED: 5, + CANCELLED: 5, +}; + +function statusRank(status: PredictionMarketLifecycleStatus): number { + return PREDICTION_MARKET_STATUS_RANK[status] ?? -1; +} + +function preferPreviousMarket( + previous: PredictionMarketLifecycleRecord, + next: PredictionMarketLifecycleRecord, +): boolean { + const previousRank = statusRank(previous.lifecycleStatus); + const nextRank = statusRank(next.lifecycleStatus); + if (nextRank < previousRank) { + return true; + } + if ( + nextRank === previousRank && + previous.winner !== "NONE" && + next.winner === "NONE" + ) { + return true; + } + return false; +} + +function mergeMarketRecord( + previous: PredictionMarketLifecycleRecord, + next: PredictionMarketLifecycleRecord, +): PredictionMarketLifecycleRecord { + const keepPrevious = preferPreviousMarket(previous, next); + const preferred = keepPrevious ? previous : next; + const fallback = keepPrevious ? next : previous; + + return { + ...fallback, + ...preferred, + duelKey: preferred.duelKey ?? fallback.duelKey ?? null, + duelId: preferred.duelId ?? fallback.duelId ?? null, + betCloseTime: preferred.betCloseTime ?? fallback.betCloseTime ?? null, + winner: preferred.winner !== "NONE" ? preferred.winner : fallback.winner, + syncedAt: + preferred.syncedAt != null + ? Math.max(preferred.syncedAt, fallback.syncedAt ?? preferred.syncedAt) + : (fallback.syncedAt ?? null), + metadata: + previous.metadata || next.metadata + ? { + ...(keepPrevious ? next.metadata ?? {} : previous.metadata ?? {}), + ...(keepPrevious ? previous.metadata ?? {} : next.metadata ?? {}), + } + : undefined, + }; +} + +function mergeDuelSnapshot( + previous: PredictionMarketsDuelSnapshot, + next: PredictionMarketsDuelSnapshot, +): PredictionMarketsDuelSnapshot { + const nextPhase = + next.phase === "IDLE" && previous.phase && previous.phase !== "IDLE" + ? previous.phase + : (next.phase ?? previous.phase ?? null); + + return { + duelKey: next.duelKey ?? previous.duelKey ?? null, + duelId: next.duelId ?? previous.duelId ?? null, + phase: nextPhase, + winner: next.winner !== "NONE" ? next.winner : previous.winner, + betCloseTime: next.betCloseTime ?? previous.betCloseTime ?? null, + agent1Name: next.agent1Name ?? previous.agent1Name ?? null, + agent2Name: next.agent2Name ?? previous.agent2Name ?? null, + }; +} + +function normalizeRendererHealth(value: unknown): BetSyncRendererHealth | null { + const candidate = asRecord(value); + if (!candidate) return null; + return { + ready: candidate.ready === true, + degradedReason: asString(candidate.degradedReason), + updatedAt: normalizePredictionMarketTimestamp(candidate.updatedAt), + }; +} + +function normalizeHlsManifest(value: unknown): BetSyncHlsManifest | null { + const candidate = asRecord(value); + if (!candidate) return null; + return { + updatedAt: normalizePredictionMarketTimestamp(candidate.updatedAt), + mediaSequence: asFiniteNumber(candidate.mediaSequence), + }; +} + +function normalizeRendererMetrics( + value: unknown, +): BetSyncRendererMetrics | null { + const candidate = asRecord(value); + if (!candidate) return null; + return { + captureFps: asFiniteNumber(candidate.captureFps), + encodeFps: asFiniteNumber(candidate.encodeFps), + droppedFrames: asFiniteNumber(candidate.droppedFrames), + renderTick: asFiniteNumber(candidate.renderTick), + duelStateTick: asFiniteNumber(candidate.duelStateTick), + latestFrameAt: normalizePredictionMarketTimestamp(candidate.latestFrameAt), + latestRenderTickAt: normalizePredictionMarketTimestamp( + candidate.latestRenderTickAt, + ), + latestDuelStateTickAt: normalizePredictionMarketTimestamp( + candidate.latestDuelStateTickAt, + ), + latestVisualChangeAt: normalizePredictionMarketTimestamp( + candidate.latestVisualChangeAt, + ), + visualChangeAgeMs: asFiniteNumber(candidate.visualChangeAgeMs), + hlsManifest: normalizeHlsManifest(candidate.hlsManifest), + }; +} + +function normalizeDelivery(value: unknown): BetSyncDelivery | null { + const candidate = asRecord(value); + if (!candidate) return null; + const mode = asString(candidate.mode); + if (mode !== "self_hls" && mode !== "external_hls") { + return null; + } + return { + mode, + provider: asString(candidate.provider), + playbackUrl: asString(candidate.playbackUrl), + hlsUrl: asString(candidate.hlsUrl), + llhlsUrl: asString(candidate.llhlsUrl), + ingestUrl: asString(candidate.ingestUrl), + }; +} + +export function parseBetSyncEvent(payload: unknown): BetSyncEvent | null { + const candidate = asRecord(payload); + if (!candidate) return null; + + const seq = asFiniteNumber(candidate.seq); + const emittedAt = normalizePredictionMarketTimestamp(candidate.emittedAt); + const sourceEpoch = asFiniteNumber(candidate.sourceEpoch); + + if (seq == null || emittedAt == null || sourceEpoch == null) { + return null; + } + + return { + schemaVersion: asFiniteNumber(candidate.schemaVersion) ?? 1, + sourceEpoch, + seq, + emittedAt, + duelId: asString(candidate.duelId), + duelKey: normalizeDuelKey(candidate.duelKey), + phase: asString(candidate.phase), + phaseVersion: asFiniteNumber(candidate.phaseVersion), + betOpenTime: normalizePredictionMarketTimestamp(candidate.betOpenTime), + betCloseTime: normalizePredictionMarketTimestamp(candidate.betCloseTime), + fightStartTime: normalizePredictionMarketTimestamp(candidate.fightStartTime), + duelEndTime: normalizePredictionMarketTimestamp(candidate.duelEndTime), + winnerId: asString(candidate.winnerId), + winnerName: asString(candidate.winnerName), + winReason: asString(candidate.winReason), + seed: asString(candidate.seed), + replayHash: asString(candidate.replayHash), + agent1: asRecord(candidate.agent1), + agent2: asRecord(candidate.agent2), + arenaPositions: asRecord(candidate.arenaPositions), + leaderboard: Array.isArray(candidate.leaderboard) + ? candidate.leaderboard + .map((entry) => asRecord(entry)) + .filter((entry): entry is JsonRecord => entry !== null) + : [], + cameraTarget: asString(candidate.cameraTarget), + rendererHealth: normalizeRendererHealth(candidate.rendererHealth), + rendererMetrics: normalizeRendererMetrics(candidate.rendererMetrics), + delivery: normalizeDelivery(candidate.delivery), + }; +} + +export function parseBetSyncBootstrapState( + payload: unknown, +): BetSyncBootstrapState | null { + const candidate = asRecord(payload); + if (!candidate) return null; + + const replay = asRecord(candidate.replay); + const sourceEpoch = + asFiniteNumber(candidate.sourceEpoch) ?? + asFiniteNumber(replay?.sourceEpoch); + const latestSeq = + asFiniteNumber(candidate.latestSeq) ?? + asFiniteNumber(replay?.latestSeq) ?? + asFiniteNumber(candidate.seq); + if (sourceEpoch == null || latestSeq == null) { + return null; + } + + return { + sourceEpoch, + latestSeq, + oldestReplaySeq: + asFiniteNumber(candidate.oldestReplaySeq) ?? + asFiniteNumber(replay?.oldestSeq), + latestEvent: + parseBetSyncEvent(candidate.latestEvent) ?? parseBetSyncEvent(candidate), + }; +} + +export function toStreamStateFromBetSyncEvent(event: BetSyncEvent): StreamState { + return { + type: "STREAMING_STATE_UPDATE", + cycle: { + cycleId: event.duelId ?? `bet-sync-${event.sourceEpoch}-${event.seq}`, + duelId: event.duelId, + duelKey: event.duelKey, + duelKeyHex: event.duelKey ? `0x${event.duelKey}` : null, + phase: event.phase ?? "IDLE", + phaseVersion: event.phaseVersion, + betOpenTime: event.betOpenTime, + betCloseTime: event.betCloseTime, + fightStartTime: event.fightStartTime, + duelEndTime: event.duelEndTime, + winnerId: event.winnerId, + winnerName: event.winnerName, + winReason: event.winReason, + seed: event.seed, + replayHash: event.replayHash, + agent1: event.agent1, + agent2: event.agent2, + arenaPositions: event.arenaPositions, + rendererHealth: event.rendererHealth, + }, + leaderboard: event.leaderboard, + cameraTarget: event.cameraTarget, + seq: event.seq, + emittedAt: event.emittedAt, + }; +} + +export function parsePredictionMarketsSurface( + payload: unknown, +): PredictionMarketsSurface | null { + const candidate = asRecord(payload); + const duel = asRecord(candidate?.duel); + if (!candidate || !duel || !Array.isArray(candidate.markets)) { + return null; + } + + return { + duel: { + duelKey: normalizeDuelKey(duel.duelKey), + duelId: asString(duel.duelId), + phase: asString(duel.phase), + winner: normalizePredictionMarketWinner(duel.winner), + betCloseTime: normalizePredictionMarketTimestamp(duel.betCloseTime), + agent1Name: asString(duel.agent1Name), + agent2Name: asString(duel.agent2Name), + }, + markets: candidate.markets.filter( + (market): market is PredictionMarketLifecycleRecord => + Boolean(market) && typeof market === "object", + ) as PredictionMarketLifecycleRecord[], + updatedAt: normalizePredictionMarketTimestamp(candidate.updatedAt), + }; +} + +export function parsePredictionMarketsOverview( + payload: unknown, +): PredictionMarketsOverviewResponse | null { + const candidate = asRecord(payload); + if (!candidate) return null; + return { + updatedAt: normalizePredictionMarketTimestamp(candidate.updatedAt), + live: parsePredictionMarketsSurface(candidate.live), + recentSettlement: parsePredictionMarketsSurface(candidate.recentSettlement), + }; +} + +export function hasMeaningfulSurface( + surface: PredictionMarketsSurface | null | undefined, +): boolean { + if (!surface) return false; + return Boolean( + surface.duel.duelKey || surface.duel.duelId || surface.markets.length, + ); +} + +export function sameDuelIdentity( + left: PredictionMarketsSurface | null | undefined, + right: PredictionMarketsSurface | null | undefined, +): boolean { + if (!left || !right) return false; + if (left.duel.duelKey && right.duel.duelKey) { + return left.duel.duelKey === right.duel.duelKey; + } + if (left.duel.duelId && right.duel.duelId) { + return left.duel.duelId === right.duel.duelId; + } + return false; +} + +export function mergePredictionMarketsSurface( + previous: PredictionMarketsSurface | null, + next: PredictionMarketsSurface | null, +): PredictionMarketsSurface | null { + if (!next) return previous; + if (!previous) return next; + + const byChain = new Map(); + for (const market of previous.markets) { + byChain.set(market.chainKey, market); + } + for (const market of next.markets) { + const existing = byChain.get(market.chainKey); + byChain.set( + market.chainKey, + existing ? mergeMarketRecord(existing, market) : market, + ); + } + + return { + duel: mergeDuelSnapshot(previous.duel, next.duel), + markets: Array.from(byChain.values()), + updatedAt: next.updatedAt, + }; +} + +export function rollPredictionMarketsOverview( + previous: PredictionMarketsOverviewResponse | null, + live: PredictionMarketsSurface | null, + updatedAt: number, +): PredictionMarketsOverviewResponse { + const nextLive = live; + let recentSettlement = previous?.recentSettlement ?? null; + + if ( + hasMeaningfulSurface(previous?.live) && + hasMeaningfulSurface(nextLive) && + !sameDuelIdentity(previous?.live, nextLive) + ) { + recentSettlement = previous?.live ?? null; + } + + return { + updatedAt, + live: nextLive, + recentSettlement, + }; +} + +export function selectBetSyncResumeSeq(params: { + lastAppliedSeq: number; +}): number { + return Math.max(0, params.lastAppliedSeq); +} + +export function selectBetSyncReplayUntilSeq(params: { + resumeSeq: number; + latestSeq: number | null; +}): number | null { + const resumeSeq = Math.max(0, params.resumeSeq); + if (resumeSeq <= 0 || params.latestSeq == null) { + return null; + } + return params.latestSeq > resumeSeq ? params.latestSeq : null; +} + +export function resolveBetSyncBootstrapCursor(params: { + previousSourceEpoch: number | null; + nextSourceEpoch: number; + previousLastAppliedSeq: number; + latestSeq: number | null; +}): { + sourceEpochChanged: boolean; + rebasedLastAppliedSeq: number; + replayUntilSeq: number | null; + replayMode: BetSyncReplayMode; +} { + const sourceEpochChanged = + params.previousSourceEpoch != null && + params.previousSourceEpoch !== params.nextSourceEpoch; + const rebasedLastAppliedSeq = sourceEpochChanged + ? 0 + : Math.max(0, params.previousLastAppliedSeq); + const replayUntilSeq = selectBetSyncReplayUntilSeq({ + resumeSeq: selectBetSyncResumeSeq({ + lastAppliedSeq: rebasedLastAppliedSeq, + }), + latestSeq: params.latestSeq, + }); + + return { + sourceEpochChanged, + rebasedLastAppliedSeq, + replayUntilSeq, + replayMode: + rebasedLastAppliedSeq > 0 && replayUntilSeq != null ? "replay" : "live", + }; +} + +export function isBetSyncEventStaleAfterSourceReset(params: { + sourceEpochChanged: boolean; + currentStreamEmittedAt: number | null; + eventEmittedAt: number; + toleranceMs: number; +}): boolean { + if (!params.sourceEpochChanged || params.currentStreamEmittedAt == null) { + return false; + } + return ( + params.eventEmittedAt + Math.max(0, params.toleranceMs) < + params.currentStreamEmittedAt + ); +} + +export function resolveBetSyncReplayMode(params: { + eventName: string; + eventSeq: number; + replayUntilSeq: number | null; + sourceEpochChanged: boolean; +}): { + replayMode: BetSyncReplayMode; + replayUntilSeq: number | null; +} { + if (params.sourceEpochChanged || params.eventName === "reset") { + return { + replayMode: "reset", + replayUntilSeq: null, + }; + } + + if (params.replayUntilSeq != null) { + if (params.eventSeq < params.replayUntilSeq) { + return { + replayMode: "replay", + replayUntilSeq: params.replayUntilSeq, + }; + } + return { + replayMode: "live", + replayUntilSeq: null, + }; + } + + return { + replayMode: "live", + replayUntilSeq: null, + }; +} diff --git a/packages/hyperbet-bsc/keeper/src/service.ts b/packages/hyperbet-bsc/keeper/src/service.ts index 9a6c770c..3ca36a2f 100644 --- a/packages/hyperbet-bsc/keeper/src/service.ts +++ b/packages/hyperbet-bsc/keeper/src/service.ts @@ -51,6 +51,7 @@ import { readKeypair, } from "./common"; import { + type DbBetSyncCheckpoint, deleteIdentityMembers, loadAll, loadPerpsMarkets, @@ -66,7 +67,20 @@ import { saveReferral, saveInvitedWallet, saveReferralFees, + saveBetSyncCheckpoint, } from "./db"; +import { + isBetSyncEventStaleAfterSourceReset, + parseBetSyncBootstrapState, + parseBetSyncEvent, + resolveBetSyncBootstrapCursor, + resolveBetSyncReplayMode, + selectBetSyncReplayUntilSeq, + selectBetSyncResumeSeq, + toStreamStateFromBetSyncEvent, + type BetSyncEvent, + type BetSyncReplayMode, +} from "./betSync"; import { modelMarketIdFromCharacterId } from "./modelMarkets"; import { isLegacyDerivedPointsWalletKey, @@ -82,6 +96,71 @@ type StreamState = { emittedAt: number; }; +type CanonicalStreamHealth = { + ready: boolean; + degradedReason: string | null; + updatedAt: number | null; +}; + +type CanonicalStreamPlayback = { + url: string | null; + kind: string | null; + renderSessionId: string | null; + presentationDelayMs: number; +}; + +type CanonicalHlsManifest = { + updatedAt: number | null; + mediaSequence: number | null; +}; + +type CanonicalRendererMetrics = { + captureFps: number | null; + encodeFps: number | null; + droppedFrames: number | null; + renderTick: number | null; + duelStateTick: number | null; + latestFrameAt: number | null; + latestRenderTickAt: number | null; + latestDuelStateTickAt: number | null; + latestVisualChangeAt: number | null; + visualChangeAgeMs: number | null; + hlsManifest: CanonicalHlsManifest | null; +}; + +type CanonicalStreamDelivery = { + mode: "self_hls" | "external_hls"; + provider: string | null; + playbackUrl: string | null; + hlsUrl: string | null; + llhlsUrl: string | null; + ingestUrl: string | null; +}; + +type CanonicalStreamSession = { + schemaVersion: number; + sourceEpoch: number | null; + seq: number; + emittedAt: number; + duelId: string | null; + duelKey: string | null; + phase: string | null; + phaseVersion: number | null; + cycle: Record; + leaderboard: any[]; + cameraTarget: string | null; + playback: CanonicalStreamPlayback | null; + rendererHealth: CanonicalStreamHealth | null; + rendererMetrics: CanonicalRendererMetrics | null; + delivery: CanonicalStreamDelivery | null; + authorityHealth: CanonicalStreamHealth; + status: { + authority: CanonicalStreamHealth; + renderer: CanonicalStreamHealth | null; + delivery: CanonicalStreamDelivery | null; + }; +}; + type BetRecord = { id: string; bettorWallet: string; @@ -140,6 +219,11 @@ type RateBucket = { lastRefillMs: number; }; +type RateLimitResult = { + allowed: boolean; + retryAfterSeconds: number; +}; + type JsonRpcRequestPayload = Record & { method: string; }; @@ -152,6 +236,7 @@ type ProxyCacheEntry = { }; const encoder = new TextEncoder(); +const decoder = new TextDecoder(); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const keeperRoot = path.resolve(__dirname, ".."); const KEEPER_BOT_HEALTH_FILE = ( @@ -222,6 +307,18 @@ function readEnvBoolean(name: string, fallback: boolean): boolean { return raw === "1" || raw === "true" || raw === "yes" || raw === "on"; } +function deriveBetSyncStateUrl(eventsUrl: string): string { + if (!eventsUrl) return ""; + try { + const url = new URL(eventsUrl); + url.pathname = url.pathname.replace(/\/events$/, "/state"); + url.search = ""; + return url.toString(); + } catch { + return ""; + } +} + const PORT = Number(process.env.PORT || 8080); const ARENA_WRITE_KEY = process.env.ARENA_EXTERNAL_BET_WRITE_KEY?.trim() || ""; const STREAM_PUBLISH_KEY = @@ -236,6 +333,18 @@ const STREAM_STATE_SOURCE_URL = process.env.STREAM_STATE_SOURCE_URL?.trim() || ""; const STREAM_STATE_SOURCE_BEARER_TOKEN = process.env.STREAM_STATE_SOURCE_BEARER_TOKEN?.trim() || ""; +const BET_SYNC_SOURCE_EVENTS_URL = + process.env.BET_SYNC_SOURCE_EVENTS_URL?.trim() || ""; +const BET_SYNC_SOURCE_STATE_URL = + process.env.BET_SYNC_SOURCE_STATE_URL?.trim() || + deriveBetSyncStateUrl(BET_SYNC_SOURCE_EVENTS_URL); +const BET_SYNC_SOURCE_BEARER_TOKEN = + process.env.BET_SYNC_SOURCE_BEARER_TOKEN?.trim() || + STREAM_STATE_SOURCE_BEARER_TOKEN; +const ACTIVE_STREAM_STATE_SOURCE_URL = + BET_SYNC_SOURCE_STATE_URL || STREAM_STATE_SOURCE_URL; +const ACTIVE_STREAM_SOURCE_BEARER_TOKEN = + BET_SYNC_SOURCE_BEARER_TOKEN || STREAM_STATE_SOURCE_BEARER_TOKEN; const STREAM_STATE_POLL_MS = Math.max( 1_000, Number(process.env.STREAM_STATE_POLL_MS || 2_000), @@ -248,6 +357,58 @@ const STREAM_STATE_SOURCE_MAX_BACKOFF_MS = Math.max( STREAM_STATE_POLL_MS, Number(process.env.STREAM_STATE_SOURCE_MAX_BACKOFF_MS || 30_000), ); +const BET_SYNC_CONNECT_TIMEOUT_MS = Math.max( + 1_000, + Number(process.env.BET_SYNC_CONNECT_TIMEOUT_MS || 10_000), +); +const BET_SYNC_RECONNECT_MIN_MS = Math.max( + 500, + Number(process.env.BET_SYNC_RECONNECT_MIN_MS || 1_000), +); +const BET_SYNC_RECONNECT_MAX_MS = Math.max( + BET_SYNC_RECONNECT_MIN_MS, + Number(process.env.BET_SYNC_RECONNECT_MAX_MS || 30_000), +); +const BET_SYNC_STALE_EVENT_TOLERANCE_MS = Math.max( + 0, + Number(process.env.BET_SYNC_STALE_EVENT_TOLERANCE_MS || 5_000), +); +const STREAM_DELIVERY_MODE = ( + process.env.STREAM_DELIVERY_MODE?.trim().toLowerCase() || "" +) as "self_hls" | "external_hls" | ""; +const STREAM_DELIVERY_PROVIDER = + process.env.STREAM_DELIVERY_PROVIDER?.trim() || ""; +const STREAM_INGEST_RTMPS_URL = + process.env.STREAM_INGEST_RTMPS_URL?.trim() || ""; +const STREAM_PLAYBACK_HLS_URL = + process.env.STREAM_PLAYBACK_HLS_URL?.trim() || ""; +const STREAM_PLAYBACK_LLHLS_URL = + process.env.STREAM_PLAYBACK_LLHLS_URL?.trim() || ""; +const STREAM_PLAYBACK_KIND = + process.env.STREAM_PLAYBACK_KIND?.trim().toLowerCase() || "hls"; +const STREAM_PRESENTATION_DELAY_MS = Math.max( + 0, + Number(process.env.STREAM_PRESENTATION_DELAY_MS || 0), +); +const STREAM_PLAYBACK_URL = + process.env.STREAM_PLAYBACK_URL?.trim() || + deriveDefaultStreamPlaybackUrl(ACTIVE_STREAM_STATE_SOURCE_URL); +const STREAM_RENDERER_HEALTH_URL = + process.env.STREAM_RENDERER_HEALTH_URL?.trim() || ""; +const STREAM_RENDERER_HEALTH_BEARER_TOKEN = + process.env.STREAM_RENDERER_HEALTH_BEARER_TOKEN?.trim() || ""; +const STREAM_RENDERER_HEALTH_POLL_MS = Math.max( + 1_000, + Number(process.env.STREAM_RENDERER_HEALTH_POLL_MS || 2_000), +); +const STREAM_RENDERER_HEALTH_TIMEOUT_MS = Math.max( + 500, + Number(process.env.STREAM_RENDERER_HEALTH_TIMEOUT_MS || 3_000), +); +const STREAM_RENDERER_HLS_FRESHNESS_MS = Math.max( + STREAM_RENDERER_HEALTH_POLL_MS, + Number(process.env.STREAM_RENDERER_HLS_FRESHNESS_MS || 15_000), +); const CONTRACT_POLL_MS = Math.max( 5_000, Number(process.env.CONTRACT_POLL_MS || 15_000), @@ -463,8 +624,25 @@ let streamLastSourceError: string | null = null; let streamSourcePollInFlight = false; let streamSourceConsecutiveFailures = 0; let streamSourceBackoffUntil = 0; +let streamRendererHealthOverride: CanonicalStreamHealth | null = null; +let streamRendererMetricsOverride: CanonicalRendererMetrics | null = null; +let streamDeliveryOverride: CanonicalStreamDelivery | null = null; +let streamRendererHealthPollInFlight = false; +let canonicalStreamSession: CanonicalStreamSession; +let betSyncSourceLatestSeq = 0; +let betSyncOldestReplaySeq: number | null = null; +let betSyncLastEventReceivedAt: number | null = null; +let betSyncLastAppliedAt: number | null = null; +let betSyncLastError: string | null = null; +let betSyncConnectedAt: number | null = null; +let betSyncConsumerRunning = false; +let betSyncReplayMode: BetSyncReplayMode = "bootstrap"; +let betSyncReplayUntilSeq: number | null = null; +let betSyncLastAppliedSeq = 0; +let betSyncSourceEpoch: number | null = null; const sseClients = new Set>(); +const sessionSseClients = new Set>(); const manifestCache = new Map(); const rateBuckets = new Map(); const proxyResponseCache = new Map(); @@ -492,6 +670,22 @@ const referralFeeShareGoldByWallet: Map = _db.referralFeeShareGoldByWallet; const treasuryFeesFromReferralsByWallet: Map = _db.treasuryFeesFromReferralsByWallet; +const persistedBetSyncCheckpoint = _db.betSyncCheckpoint; + +if (persistedBetSyncCheckpoint) { + betSyncSourceEpoch = persistedBetSyncCheckpoint.sourceEpoch; + betSyncSourceLatestSeq = persistedBetSyncCheckpoint.lastSeenSeq; + betSyncLastAppliedSeq = persistedBetSyncCheckpoint.lastAppliedSeq; + betSyncLastAppliedAt = + persistedBetSyncCheckpoint.updatedAt > 0 + ? persistedBetSyncCheckpoint.updatedAt + : null; + betSyncReplayMode = + (persistedBetSyncCheckpoint.replayMode as BetSyncReplayMode | null) ?? + (persistedBetSyncCheckpoint.lastAppliedSeq > 0 ? "live" : "bootstrap"); + betSyncLastError = persistedBetSyncCheckpoint.degradedReason; + streamLastSourceError = persistedBetSyncCheckpoint.degradedReason; +} const parsers: { solana: ParserState; @@ -561,6 +755,7 @@ const avaxClient = avaxRpcUrl && avaxContractAddress ? createPublicClient({ transport: http(avaxRpcUrl) }) : null; +canonicalStreamSession = buildCanonicalStreamSessionFromStreamState(streamState); const EVM_RPC_PROXY_TARGETS = { bsc: bscRpcUrl, base: baseRpcUrl, @@ -1248,14 +1443,15 @@ function handlePerpsMarkets(req: Request): Response { } function handleDuelContext(req: Request): Response { + const state = toStreamStateFromCanonicalSession(canonicalStreamSession); return jsonResponse( req, { type: "STREAMING_DUEL_CONTEXT", - cycle: streamState.cycle, - leaderboard: streamState.leaderboard, - cameraTarget: streamState.cameraTarget, - updatedAt: streamState.emittedAt, + cycle: state.cycle, + leaderboard: state.leaderboard, + cameraTarget: state.cameraTarget, + updatedAt: state.emittedAt, }, 200, { @@ -1664,9 +1860,12 @@ function checkRateLimit( pathname: string, limitPerMinute: number, burst: number, -): boolean { +): RateLimitResult { if (DISABLE_RATE_LIMIT) { - return true; + return { + allowed: true, + retryAfterSeconds: 0, + }; } const now = Date.now(); @@ -1689,12 +1888,23 @@ function checkRateLimit( if (bucket.tokens < 1) { rateBuckets.set(key, bucket); - return false; + const deficit = 1 - bucket.tokens; + const retryAfterSeconds = Math.max( + 1, + Math.ceil((deficit * 60) / limitPerMinute), + ); + return { + allowed: false, + retryAfterSeconds, + }; } bucket.tokens -= 1; rateBuckets.set(key, bucket); - return true; + return { + allowed: true, + retryAfterSeconds: 0, + }; } function requireWriteAuth( @@ -1820,6 +2030,533 @@ function toStreamState(payload: any): StreamState | null { }; } +function deriveDefaultStreamPlaybackUrl(sourceUrl: string): string { + if (!sourceUrl) return ""; + try { + const parsed = new URL(sourceUrl); + parsed.pathname = "/live/stream.m3u8"; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); + } catch { + return ""; + } +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" + ? (value as Record) + : null; +} + +function asFiniteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function asNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function resolveConfiguredDeliveryMode(): "self_hls" | "external_hls" { + if (STREAM_DELIVERY_MODE === "self_hls") { + return "self_hls"; + } + if (STREAM_DELIVERY_MODE === "external_hls") { + return "external_hls"; + } + if ( + STREAM_PLAYBACK_LLHLS_URL || + STREAM_PLAYBACK_HLS_URL || + STREAM_INGEST_RTMPS_URL + ) { + return "external_hls"; + } + return "self_hls"; +} + +function normalizeCanonicalRendererMetrics( + value: unknown, +): CanonicalRendererMetrics | null { + const candidate = asRecord(value); + if (!candidate) return null; + const hlsManifest = asRecord(candidate.hlsManifest); + return { + captureFps: asFiniteNumber(candidate.captureFps), + encodeFps: asFiniteNumber(candidate.encodeFps), + droppedFrames: asFiniteNumber(candidate.droppedFrames), + renderTick: asFiniteNumber(candidate.renderTick), + duelStateTick: asFiniteNumber(candidate.duelStateTick), + latestFrameAt: asFiniteNumber(candidate.latestFrameAt), + latestRenderTickAt: asFiniteNumber(candidate.latestRenderTickAt), + latestDuelStateTickAt: asFiniteNumber(candidate.latestDuelStateTickAt), + latestVisualChangeAt: asFiniteNumber(candidate.latestVisualChangeAt), + visualChangeAgeMs: asFiniteNumber(candidate.visualChangeAgeMs), + hlsManifest: hlsManifest + ? { + updatedAt: asFiniteNumber(hlsManifest.updatedAt), + mediaSequence: asFiniteNumber(hlsManifest.mediaSequence), + } + : null, + }; +} + +function normalizeCanonicalDelivery( + value: unknown, +): CanonicalStreamDelivery | null { + const candidate = asRecord(value); + if (!candidate) return null; + const mode = asNonEmptyString(candidate.mode); + if (mode !== "self_hls" && mode !== "external_hls") { + return null; + } + return { + mode, + provider: asNonEmptyString(candidate.provider), + playbackUrl: asNonEmptyString(candidate.playbackUrl), + hlsUrl: asNonEmptyString(candidate.hlsUrl), + llhlsUrl: asNonEmptyString(candidate.llhlsUrl), + ingestUrl: asNonEmptyString(candidate.ingestUrl), + }; +} + +function buildCanonicalStreamDelivery( + value: unknown, +): CanonicalStreamDelivery | null { + const configuredMode = resolveConfiguredDeliveryMode(); + const candidate = normalizeCanonicalDelivery(value); + const mode = candidate?.mode ?? configuredMode; + const hlsUrl = STREAM_PLAYBACK_HLS_URL || candidate?.hlsUrl || null; + const llhlsUrl = STREAM_PLAYBACK_LLHLS_URL || candidate?.llhlsUrl || null; + const playbackUrl = + mode === "external_hls" + ? llhlsUrl || hlsUrl || candidate?.playbackUrl || STREAM_PLAYBACK_URL || null + : hlsUrl || llhlsUrl || STREAM_PLAYBACK_URL || candidate?.playbackUrl || null; + + if ( + !playbackUrl && + !STREAM_INGEST_RTMPS_URL && + !candidate?.ingestUrl && + !candidate?.provider && + !STREAM_DELIVERY_PROVIDER + ) { + return null; + } + + return { + mode, + provider: STREAM_DELIVERY_PROVIDER || candidate?.provider || null, + playbackUrl, + hlsUrl, + llhlsUrl, + ingestUrl: STREAM_INGEST_RTMPS_URL || candidate?.ingestUrl || null, + }; +} + +function normalizeCanonicalStreamHealth( + value: unknown, + fallbackUpdatedAt: number, + fallbackReady: boolean, +): CanonicalStreamHealth { + const candidate = + value && typeof value === "object" + ? (value as Record) + : null; + return { + ready: + candidate?.ready === true || + (candidate?.ready !== false && fallbackReady), + degradedReason: + typeof candidate?.degradedReason === "string" + ? candidate.degradedReason + : null, + updatedAt: + typeof candidate?.updatedAt === "number" && + Number.isFinite(candidate.updatedAt) + ? candidate.updatedAt + : fallbackUpdatedAt, + }; +} + +function canonicalStreamHealthEquals( + left: CanonicalStreamHealth | null, + right: CanonicalStreamHealth | null, +): boolean { + return ( + left?.ready === right?.ready && + left?.degradedReason === right?.degradedReason && + left?.updatedAt === right?.updatedAt + ); +} + +function extractFreshHlsManifestUpdatedAt(payload: unknown): number | null { + if (!payload || typeof payload !== "object") return null; + const candidate = payload as Record; + const hlsManifest = + candidate.hlsManifest && typeof candidate.hlsManifest === "object" + ? (candidate.hlsManifest as Record) + : null; + const updatedAt = + typeof hlsManifest?.updatedAt === "number" && + Number.isFinite(hlsManifest.updatedAt) + ? hlsManifest.updatedAt + : null; + const mediaSequence = + typeof hlsManifest?.mediaSequence === "number" && + Number.isFinite(hlsManifest.mediaSequence) + ? hlsManifest.mediaSequence + : null; + if (updatedAt == null || mediaSequence == null) return null; + return Date.now() - updatedAt <= STREAM_RENDERER_HLS_FRESHNESS_MS + ? updatedAt + : null; +} + +function toRendererHealthOverride(payload: unknown): CanonicalStreamHealth | null { + if (!payload || typeof payload !== "object") return null; + const candidate = payload as Record; + const statusCandidate = + candidate.status && typeof candidate.status === "object" + ? (candidate.status as Record) + : null; + const healthCandidate = + (candidate.rendererHealth && + typeof candidate.rendererHealth === "object" + ? candidate.rendererHealth + : null) ?? + (statusCandidate?.renderer && + typeof statusCandidate.renderer === "object" + ? statusCandidate.renderer + : null) ?? + (typeof candidate.ready === "boolean" || + typeof candidate.degradedReason === "string" || + typeof candidate.updatedAt === "number" + ? candidate + : null); + + if (!healthCandidate) return null; + const normalized = normalizeCanonicalStreamHealth( + healthCandidate, + Date.now(), + false, + ); + const freshHlsUpdatedAt = extractFreshHlsManifestUpdatedAt(candidate); + if (freshHlsUpdatedAt != null && !normalized.ready) { + return { + ready: true, + degradedReason: null, + updatedAt: freshHlsUpdatedAt, + }; + } + return normalized; +} + +function toRendererMetricsOverride( + payload: unknown, +): CanonicalRendererMetrics | null { + if (!payload || typeof payload !== "object") return null; + const candidate = payload as Record; + const metricsCandidate = + (candidate.metrics && typeof candidate.metrics === "object" + ? candidate.metrics + : null) ?? + (candidate.rendererMetrics && typeof candidate.rendererMetrics === "object" + ? candidate.rendererMetrics + : null); + + if (!metricsCandidate) return null; + const normalized = normalizeCanonicalRendererMetrics(metricsCandidate); + if (!normalized) return null; + const topLevelHlsManifest = asRecord(candidate.hlsManifest); + if (topLevelHlsManifest) { + normalized.hlsManifest = { + updatedAt: asFiniteNumber(topLevelHlsManifest.updatedAt), + mediaSequence: asFiniteNumber(topLevelHlsManifest.mediaSequence), + }; + } else if (!normalized.hlsManifest) { + normalized.hlsManifest = { + updatedAt: extractFreshHlsManifestUpdatedAt(candidate), + mediaSequence: null, + }; + } + return normalized; +} + +function toDeliveryOverride(payload: unknown): CanonicalStreamDelivery | null { + if (!payload || typeof payload !== "object") return null; + const candidate = payload as Record; + return buildCanonicalStreamDelivery(candidate.delivery); +} + +function resolveCanonicalRendererHealth( + value: unknown, + fallbackUpdatedAt: number, +): CanonicalStreamHealth | null { + return ( + streamRendererHealthOverride ?? + normalizeCanonicalStreamHealth( + value, + fallbackUpdatedAt, + Boolean( + STREAM_PLAYBACK_URL || + STREAM_PLAYBACK_HLS_URL || + STREAM_PLAYBACK_LLHLS_URL, + ), + ) + ); +} + +function resolveCanonicalRendererMetrics( + value: unknown, +): CanonicalRendererMetrics | null { + return ( + streamRendererMetricsOverride ?? + normalizeCanonicalRendererMetrics(value) + ); +} + +function resolveCanonicalDelivery( + value: unknown, +): CanonicalStreamDelivery | null { + return streamDeliveryOverride ?? buildCanonicalStreamDelivery(value); +} + +function buildCanonicalStreamPlayback( + cycle: Record, + playbackCandidate: Record | null, + delivery: CanonicalStreamDelivery | null, + fallbackRenderSessionId: string, +): CanonicalStreamPlayback | null { + const candidateUrl = + typeof playbackCandidate?.url === "string" + ? playbackCandidate.url.trim() + : ""; + const playbackUrl = + delivery?.playbackUrl || STREAM_PLAYBACK_URL || candidateUrl; + if (!playbackUrl) return null; + + return { + url: playbackUrl, + kind: + delivery?.mode === "external_hls" && delivery.llhlsUrl + ? "llhls" + : delivery?.mode === "external_hls" && delivery.hlsUrl + ? "hls" + : STREAM_PLAYBACK_URL.length > 0 + ? STREAM_PLAYBACK_KIND + : typeof playbackCandidate?.kind === "string" + ? playbackCandidate.kind + : STREAM_PLAYBACK_KIND, + renderSessionId: + typeof playbackCandidate?.renderSessionId === "string" + ? playbackCandidate.renderSessionId + : ((typeof cycle.duelId === "string" && cycle.duelId) || + (typeof cycle.cycleId === "string" && cycle.cycleId) || + fallbackRenderSessionId), + presentationDelayMs: + typeof playbackCandidate?.presentationDelayMs === "number" && + Number.isFinite(playbackCandidate.presentationDelayMs) + ? Math.max(0, playbackCandidate.presentationDelayMs) + : STREAM_PRESENTATION_DELAY_MS, + }; +} + +function buildCanonicalStreamSessionFromStreamState( + next: StreamState, +): CanonicalStreamSession { + const emittedAt = + typeof next.emittedAt === "number" && Number.isFinite(next.emittedAt) + ? next.emittedAt + : Date.now(); + const duelKeyHex = + typeof next.cycle?.duelKeyHex === "string" + ? next.cycle.duelKeyHex.trim() + : null; + const delivery = resolveCanonicalDelivery(next.cycle?.delivery); + const playback = buildCanonicalStreamPlayback( + next.cycle, + null, + delivery, + `stream-${next.seq}`, + ); + const rendererHealth = resolveCanonicalRendererHealth( + next.cycle?.rendererHealth, + emittedAt, + ); + const rendererMetrics = resolveCanonicalRendererMetrics( + next.cycle?.rendererMetrics, + ); + const authorityHealth = normalizeCanonicalStreamHealth( + { + ready: Boolean(playback?.url), + degradedReason: + playback?.url ? null : "playback_unconfigured", + updatedAt: emittedAt, + }, + emittedAt, + Boolean(playback?.url), + ); + + return { + schemaVersion: 1, + sourceEpoch: null, + seq: next.seq, + emittedAt, + duelId: + typeof next.cycle?.duelId === "string" ? next.cycle.duelId : null, + duelKey: duelKeyHex ? duelKeyHex.replace(/^0x/i, "").toLowerCase() : null, + phase: + typeof next.cycle?.phase === "string" ? next.cycle.phase : null, + phaseVersion: + typeof next.cycle?.phaseVersion === "number" && + Number.isFinite(next.cycle.phaseVersion) + ? next.cycle.phaseVersion + : null, + cycle: next.cycle, + leaderboard: Array.isArray(next.leaderboard) ? next.leaderboard : [], + cameraTarget: + typeof next.cameraTarget === "string" || next.cameraTarget === null + ? next.cameraTarget + : null, + playback, + rendererHealth, + rendererMetrics, + delivery, + authorityHealth, + status: { + authority: authorityHealth, + renderer: rendererHealth, + delivery, + }, + }; +} + +function toCanonicalStreamSession(payload: unknown): CanonicalStreamSession | null { + if (!payload || typeof payload !== "object") return null; + const candidate = payload as Record; + const cycle = + candidate.cycle && typeof candidate.cycle === "object" + ? (candidate.cycle as Record) + : null; + if (!cycle) return null; + + const emittedAt = + typeof candidate.emittedAt === "number" && Number.isFinite(candidate.emittedAt) + ? candidate.emittedAt + : Date.now(); + const statusCandidate = + candidate.status && typeof candidate.status === "object" + ? (candidate.status as Record) + : null; + const rendererHealth = resolveCanonicalRendererHealth( + candidate.rendererHealth ?? statusCandidate?.renderer, + emittedAt, + ); + const rendererMetrics = resolveCanonicalRendererMetrics( + candidate.rendererMetrics, + ); + const delivery = resolveCanonicalDelivery( + candidate.delivery ?? statusCandidate?.delivery, + ); + const authorityHealth = normalizeCanonicalStreamHealth( + candidate.authorityHealth ?? statusCandidate?.authority, + emittedAt, + Boolean( + delivery?.playbackUrl || + STREAM_PLAYBACK_URL || + STREAM_PLAYBACK_HLS_URL || + STREAM_PLAYBACK_LLHLS_URL, + ), + ); + const playbackCandidate = + candidate.playback && typeof candidate.playback === "object" + ? (candidate.playback as Record) + : null; + const playback = buildCanonicalStreamPlayback( + cycle, + playbackCandidate, + delivery, + "", + ); + + return { + schemaVersion: + typeof candidate.schemaVersion === "number" && + Number.isFinite(candidate.schemaVersion) + ? candidate.schemaVersion + : 1, + sourceEpoch: + typeof candidate.sourceEpoch === "number" && + Number.isFinite(candidate.sourceEpoch) + ? candidate.sourceEpoch + : null, + seq: + typeof candidate.seq === "number" && Number.isFinite(candidate.seq) + ? candidate.seq + : streamSeq + 1, + emittedAt, + duelId: + typeof candidate.duelId === "string" + ? candidate.duelId + : typeof cycle.duelId === "string" + ? cycle.duelId + : null, + duelKey: + typeof candidate.duelKey === "string" + ? candidate.duelKey.replace(/^0x/i, "").toLowerCase() + : typeof cycle.duelKeyHex === "string" + ? cycle.duelKeyHex.replace(/^0x/i, "").toLowerCase() + : null, + phase: + typeof candidate.phase === "string" + ? candidate.phase + : typeof cycle.phase === "string" + ? cycle.phase + : null, + phaseVersion: + typeof candidate.phaseVersion === "number" && + Number.isFinite(candidate.phaseVersion) + ? candidate.phaseVersion + : typeof cycle.phaseVersion === "number" && + Number.isFinite(cycle.phaseVersion) + ? cycle.phaseVersion + : null, + cycle, + leaderboard: Array.isArray(candidate.leaderboard) ? candidate.leaderboard : [], + cameraTarget: + typeof candidate.cameraTarget === "string" || candidate.cameraTarget === null + ? (candidate.cameraTarget as string | null) + : null, + playback, + rendererHealth, + rendererMetrics, + delivery, + authorityHealth, + status: { + authority: authorityHealth, + renderer: rendererHealth, + delivery, + }, + }; +} + +function toStreamStateFromCanonicalSession( + session: CanonicalStreamSession, +): StreamState { + return { + type: "STREAMING_STATE_UPDATE", + cycle: { + ...session.cycle, + rendererHealth: session.rendererHealth, + }, + leaderboard: session.leaderboard, + cameraTarget: session.cameraTarget, + seq: session.seq, + emittedAt: session.emittedAt, + }; +} + function sendSse( controller: ReadableStreamDefaultController, event: string, @@ -1841,23 +2578,382 @@ function broadcastStreamState(nextState: StreamState, event = "state"): void { } } -function publishStreamState(next: StreamState, sourceLabel: string): void { +function broadcastCanonicalStreamSession( + nextSession: CanonicalStreamSession, + event = "session", +): void { + for (const controller of sessionSseClients) { + try { + sendSse(controller, event, nextSession.seq, nextSession); + } catch { + sessionSseClients.delete(controller); + } + } +} + +function publishCanonicalStreamSession( + next: CanonicalStreamSession, + sourceLabel: string, +): void { + const emittedAt = + typeof next.emittedAt === "number" && Number.isFinite(next.emittedAt) + ? next.emittedAt + : Date.now(); + const resolvedRendererHealth = resolveCanonicalRendererHealth( + next.rendererHealth ?? next.status?.renderer, + emittedAt, + ); + const resolvedRendererMetrics = resolveCanonicalRendererMetrics( + next.rendererMetrics, + ); + const resolvedDelivery = resolveCanonicalDelivery( + next.delivery ?? next.status?.delivery, + ); + const resolvedPlayback = buildCanonicalStreamPlayback( + next.cycle, + next.playback as Record | null, + resolvedDelivery, + next.duelId || `stream-${streamSeq + 1}`, + ); streamSeq = Math.max(streamSeq + 1, next.seq || streamSeq + 1); - streamState = { + canonicalStreamSession = { ...next, - type: "STREAMING_STATE_UPDATE", + cycle: { + ...next.cycle, + rendererHealth: resolvedRendererHealth, + }, seq: streamSeq, emittedAt: Date.now(), + playback: resolvedPlayback, + rendererHealth: resolvedRendererHealth, + rendererMetrics: resolvedRendererMetrics, + delivery: resolvedDelivery, + authorityHealth: next.authorityHealth, + status: { + authority: next.authorityHealth, + renderer: resolvedRendererHealth, + delivery: resolvedDelivery, + }, }; + streamState = toStreamStateFromCanonicalSession(canonicalStreamSession); streamLastUpdatedAt = Date.now(); streamLastSourceError = null; persistStreamStateSnapshot(streamState); broadcastStreamState(streamState, "state"); + broadcastCanonicalStreamSession(canonicalStreamSession, "session"); console.log( `[${nowIso()}] [stream] updated from ${sourceLabel} cycle=${streamState.cycle?.cycleId ?? "unknown"} phase=${streamState.cycle?.phase ?? "unknown"}`, ); } +function publishStreamState(next: StreamState, sourceLabel: string): void { + publishCanonicalStreamSession( + buildCanonicalStreamSessionFromStreamState({ + ...next, + type: "STREAMING_STATE_UPDATE", + seq: + typeof next.seq === "number" && Number.isFinite(next.seq) + ? next.seq + : streamSeq + 1, + emittedAt: + typeof next.emittedAt === "number" && Number.isFinite(next.emittedAt) + ? next.emittedAt + : Date.now(), + }), + sourceLabel, + ); +} + +function nextBetSyncCheckpointState( + next: Partial = {}, +): DbBetSyncCheckpoint { + return { + sourceEpoch: betSyncSourceEpoch ?? 0, + lastSeenSeq: Math.max(betSyncSourceLatestSeq, betSyncLastAppliedSeq), + lastAppliedSeq: betSyncLastAppliedSeq, + replayMode: betSyncReplayMode, + degradedReason: betSyncLastError, + updatedAt: Date.now(), + ...next, + }; +} + +function persistBetSyncCheckpointState( + next: Partial = {}, +): void { + saveBetSyncCheckpoint(nextBetSyncCheckpointState(next)); +} + +function buildBetSyncHeaders(resumeSeq?: number): Record { + const headers: Record = { + connection: "close", + }; + if (BET_SYNC_SOURCE_BEARER_TOKEN) { + headers.authorization = `Bearer ${BET_SYNC_SOURCE_BEARER_TOKEN}`; + } + if (typeof resumeSeq === "number" && Number.isFinite(resumeSeq) && resumeSeq > 0) { + headers["last-event-id"] = String(resumeSeq); + } + return headers; +} + +function buildCanonicalStreamSessionFromBetSyncEvent( + event: BetSyncEvent, +): CanonicalStreamSession { + const nextState = toStreamStateFromBetSyncEvent(event) as unknown as StreamState; + const base = buildCanonicalStreamSessionFromStreamState(nextState); + return { + ...base, + schemaVersion: event.schemaVersion, + sourceEpoch: event.sourceEpoch, + seq: event.seq, + emittedAt: event.emittedAt, + duelId: event.duelId, + duelKey: event.duelKey, + phase: event.phase, + phaseVersion: event.phaseVersion, + rendererMetrics: resolveCanonicalRendererMetrics(event.rendererMetrics), + delivery: resolveCanonicalDelivery(event.delivery), + }; +} + +function applyBetSyncEvent( + event: BetSyncEvent, + sourceLabel: string, +): void { + betSyncSourceLatestSeq = Math.max(betSyncSourceLatestSeq, event.seq); + betSyncLastEventReceivedAt = Date.now(); + const sourceEpochChanged = + betSyncSourceEpoch != null && event.sourceEpoch !== betSyncSourceEpoch; + const currentAppliedEmittedAt = + typeof canonicalStreamSession.emittedAt === "number" && + Number.isFinite(canonicalStreamSession.emittedAt) + ? canonicalStreamSession.emittedAt + : null; + + if ( + isBetSyncEventStaleAfterSourceReset({ + sourceEpochChanged, + currentStreamEmittedAt: currentAppliedEmittedAt, + eventEmittedAt: event.emittedAt, + toleranceMs: BET_SYNC_STALE_EVENT_TOLERANCE_MS, + }) + ) { + betSyncLastError = "stale_source_reset_event"; + streamLastSourceError = betSyncLastError; + persistBetSyncCheckpointState({ + sourceEpoch: event.sourceEpoch, + lastSeenSeq: betSyncSourceLatestSeq, + degradedReason: betSyncLastError, + }); + return; + } + + if (!sourceEpochChanged && event.seq <= betSyncLastAppliedSeq) { + persistBetSyncCheckpointState({ + sourceEpoch: event.sourceEpoch, + lastSeenSeq: betSyncSourceLatestSeq, + degradedReason: null, + }); + return; + } + + publishCanonicalStreamSession( + buildCanonicalStreamSessionFromBetSyncEvent(event), + sourceLabel, + ); + betSyncSourceEpoch = event.sourceEpoch; + betSyncLastAppliedSeq = event.seq; + betSyncLastAppliedAt = Date.now(); + if (sourceEpochChanged) { + betSyncReplayUntilSeq = null; + } + betSyncLastError = null; + streamLastSourceError = null; + persistBetSyncCheckpointState({ + sourceEpoch: event.sourceEpoch, + lastSeenSeq: betSyncSourceLatestSeq, + lastAppliedSeq: betSyncLastAppliedSeq, + replayMode: betSyncReplayMode, + degradedReason: null, + }); + resetStreamSourceFailures(); +} + +async function bootstrapBetSyncState(): Promise { + if (!BET_SYNC_SOURCE_STATE_URL) return; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), BET_SYNC_CONNECT_TIMEOUT_MS); + try { + const response = await fetch(BET_SYNC_SOURCE_STATE_URL, { + cache: "no-store", + headers: buildBetSyncHeaders(), + signal: controller.signal, + }); + streamLastSourcePollAt = Date.now(); + if (!response.ok) { + throw new Error(`bootstrap failed (${response.status})`); + } + + const state = parseBetSyncBootstrapState(await response.json()); + if (!state) { + throw new Error("bootstrap payload was invalid"); + } + + const bootstrapCursor = resolveBetSyncBootstrapCursor({ + previousSourceEpoch: betSyncSourceEpoch, + nextSourceEpoch: state.sourceEpoch, + previousLastAppliedSeq: betSyncLastAppliedSeq, + latestSeq: state.latestSeq, + }); + + if (bootstrapCursor.sourceEpochChanged) { + betSyncLastAppliedAt = null; + } + + betSyncSourceEpoch = state.sourceEpoch; + betSyncSourceLatestSeq = state.latestSeq; + betSyncOldestReplaySeq = state.oldestReplaySeq; + betSyncLastAppliedSeq = bootstrapCursor.rebasedLastAppliedSeq; + betSyncReplayUntilSeq = bootstrapCursor.replayUntilSeq; + betSyncReplayMode = bootstrapCursor.replayMode; + persistBetSyncCheckpointState({ + sourceEpoch: state.sourceEpoch, + lastSeenSeq: state.latestSeq, + lastAppliedSeq: betSyncLastAppliedSeq, + replayMode: betSyncReplayMode, + degradedReason: null, + }); + + if (state.latestEvent && betSyncReplayUntilSeq == null) { + applyBetSyncEvent(state.latestEvent, "bet-sync-bootstrap"); + } + } finally { + clearTimeout(timeoutId); + } +} + +async function consumeBetSyncEventsOnce(): Promise { + if (!BET_SYNC_SOURCE_EVENTS_URL) return; + + const url = new URL(BET_SYNC_SOURCE_EVENTS_URL); + const resumeSeq = selectBetSyncResumeSeq({ + lastAppliedSeq: betSyncLastAppliedSeq, + }); + if (resumeSeq > 0) { + url.searchParams.set("since", String(resumeSeq)); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), BET_SYNC_CONNECT_TIMEOUT_MS); + const response = await fetch(url, { + cache: "no-store", + headers: { + ...buildBetSyncHeaders(resumeSeq), + accept: "text/event-stream", + }, + signal: controller.signal, + }); + clearTimeout(timeoutId); + + if (!response.ok || !response.body) { + throw new Error(`bet-sync stream failed (${response.status})`); + } + + betSyncConnectedAt = Date.now(); + let buffer = ""; + for await (const chunk of response.body as unknown as AsyncIterable) { + buffer += decoder.decode(chunk, { stream: true }); + let boundary = buffer.indexOf("\n\n"); + while (boundary >= 0) { + const frame = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + boundary = buffer.indexOf("\n\n"); + + if (!frame.trim() || frame.startsWith(":")) { + continue; + } + + const lines = frame.split(/\r?\n/); + let eventName = "message"; + let data = ""; + for (const line of lines) { + if (line.startsWith("event:")) { + eventName = line.slice(6).trim(); + continue; + } + if (line.startsWith("data:")) { + data += `${line.slice(5).trim()}\n`; + } + } + + if (eventName === "heartbeat" || !data.trim()) { + continue; + } + + let decodedPayload: unknown; + try { + decodedPayload = JSON.parse(data); + } catch { + continue; + } + + const parsed = parseBetSyncEvent(decodedPayload); + if (!parsed) { + continue; + } + + const replayDecision = resolveBetSyncReplayMode({ + eventName, + eventSeq: parsed.seq, + replayUntilSeq: betSyncReplayUntilSeq, + sourceEpochChanged: + betSyncSourceEpoch != null && parsed.sourceEpoch !== betSyncSourceEpoch, + }); + betSyncReplayMode = replayDecision.replayMode; + betSyncReplayUntilSeq = replayDecision.replayUntilSeq; + if (eventName === "reset") { + betSyncOldestReplaySeq = parsed.seq; + } + + applyBetSyncEvent(parsed, `bet-sync-${eventName}`); + } + } +} + +function startBetSyncConsumer(): void { + if (!BET_SYNC_SOURCE_EVENTS_URL || betSyncConsumerRunning) return; + betSyncConsumerRunning = true; + void (async () => { + let reconnectDelayMs = BET_SYNC_RECONNECT_MIN_MS; + while (true) { + try { + await bootstrapBetSyncState(); + await consumeBetSyncEventsOnce(); + reconnectDelayMs = BET_SYNC_RECONNECT_MIN_MS; + betSyncLastError = null; + } catch (error) { + betSyncLastError = + error instanceof Error ? error.message : "bet-sync consumer failed"; + streamLastSourceError = betSyncLastError; + persistBetSyncCheckpointState({ + replayMode: betSyncReplayMode, + degradedReason: betSyncLastError, + }); + registerStreamSourceFailure(streamLastSourceError); + console.warn( + `[${nowIso()}] [bet-sync] consumer error: ${betSyncLastError}; reconnecting in ${reconnectDelayMs}ms`, + ); + await new Promise((resolve) => setTimeout(resolve, reconnectDelayMs)); + reconnectDelayMs = Math.min( + BET_SYNC_RECONNECT_MAX_MS, + reconnectDelayMs * 2, + ); + } + } + })(); +} + function nextStreamSourceBackoffMs(): number { const step = Math.min(streamSourceConsecutiveFailures, 5); return Math.min( @@ -1887,7 +2983,7 @@ function resetStreamSourceFailures(): void { } async function pollStreamStateSource(): Promise { - if (!STREAM_STATE_SOURCE_URL) return; + if (!ACTIVE_STREAM_STATE_SOURCE_URL) return; if (streamSourcePollInFlight) return; if (Date.now() < streamSourceBackoffUntil) return; @@ -1900,12 +2996,12 @@ async function pollStreamStateSource(): Promise { try { const headers: Record = {}; - if (STREAM_STATE_SOURCE_BEARER_TOKEN) { - headers.authorization = `Bearer ${STREAM_STATE_SOURCE_BEARER_TOKEN}`; + if (ACTIVE_STREAM_SOURCE_BEARER_TOKEN) { + headers.authorization = `Bearer ${ACTIVE_STREAM_SOURCE_BEARER_TOKEN}`; } headers.connection = "close"; - const response = await fetch(STREAM_STATE_SOURCE_URL, { + const response = await fetch(ACTIVE_STREAM_STATE_SOURCE_URL, { cache: "no-store", headers, signal: controller.signal, @@ -1923,6 +3019,28 @@ async function pollStreamStateSource(): Promise { } const payload = await response.json(); + const nextSession = + toCanonicalStreamSession(payload) || + toCanonicalStreamSession((payload as Record)?.data); + + if (nextSession) { + const changed = + canonicalStreamSession.seq !== nextSession.seq || + canonicalStreamSession.duelId !== nextSession.duelId || + canonicalStreamSession.phase !== nextSession.phase || + canonicalStreamSession.playback?.url !== nextSession.playback?.url || + canonicalStreamSession.rendererHealth?.ready !== + nextSession.rendererHealth?.ready || + canonicalStreamSession.rendererHealth?.degradedReason !== + nextSession.rendererHealth?.degradedReason; + if (changed) { + publishCanonicalStreamSession(nextSession, "poll"); + } + streamLastSourceError = null; + resetStreamSourceFailures(); + return; + } + const nextState = toStreamState(payload) || toStreamState((payload as Record)?.data); @@ -1952,6 +3070,69 @@ async function pollStreamStateSource(): Promise { } } +async function pollRendererHealthSource(): Promise { + if (!STREAM_RENDERER_HEALTH_URL || streamRendererHealthPollInFlight) return; + + streamRendererHealthPollInFlight = true; + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + STREAM_RENDERER_HEALTH_TIMEOUT_MS, + ); + + try { + const headers: Record = { connection: "close" }; + if (STREAM_RENDERER_HEALTH_BEARER_TOKEN) { + headers.authorization = `Bearer ${STREAM_RENDERER_HEALTH_BEARER_TOKEN}`; + } + const response = await fetch(STREAM_RENDERER_HEALTH_URL, { + cache: "no-store", + headers, + signal: controller.signal, + }); + + if (!response.ok) { + try { + await response.body?.cancel(); + } catch { + // Ignore cancellation issues for already-closed streams. + } + return; + } + + const payload = await response.json(); + const nextHealth = + toRendererHealthOverride(payload) || + toRendererHealthOverride((payload as Record)?.data); + const nextMetrics = + toRendererMetricsOverride(payload) || + toRendererMetricsOverride((payload as Record)?.data); + const nextDelivery = + toDeliveryOverride(payload) || + toDeliveryOverride((payload as Record)?.data); + if (!nextHealth && !nextMetrics && !nextDelivery) return; + + if ( + canonicalStreamHealthEquals(streamRendererHealthOverride, nextHealth) && + JSON.stringify(streamRendererMetricsOverride) === + JSON.stringify(nextMetrics) && + JSON.stringify(streamDeliveryOverride) === JSON.stringify(nextDelivery) + ) { + return; + } + + streamRendererHealthOverride = nextHealth; + streamRendererMetricsOverride = nextMetrics; + streamDeliveryOverride = nextDelivery; + publishCanonicalStreamSession(canonicalStreamSession, "renderer-health"); + } catch { + // Defer to the canonical authority when the override source is unavailable. + } finally { + clearTimeout(timeoutId); + streamRendererHealthPollInFlight = false; + } +} + function connectedSseCount(): number { return sseClients.size; } @@ -1967,7 +3148,7 @@ function startKeeperBotIfEnabled(): void { const childEnv = { ...process.env, GAME_URL: process.env.GAME_URL || `http://127.0.0.1:${PORT}`, - EVM_KEEPER_CHAINS: process.env.EVM_KEEPER_CHAINS || "bsc,base", + EVM_KEEPER_CHAINS: process.env.EVM_KEEPER_CHAINS || "bsc", KEEPER_BOT_HEALTH_FILE, }; @@ -3090,14 +4271,24 @@ const server = Bun.serve({ fetch: async (req: Request) => { const url = new URL(req.url); const isWriteRoute = isWriteRateLimitedRoute(req.method, url.pathname); - const allowed = checkRateLimit( + const rateLimit = checkRateLimit( req, url.pathname, isWriteRoute ? WRITE_RATE_LIMIT_PER_MINUTE : READ_RATE_LIMIT_PER_MINUTE, isWriteRoute ? WRITE_RATE_LIMIT_BURST : READ_RATE_LIMIT_BURST, ); - if (!allowed) { - return jsonResponse(req, { error: "Rate limit exceeded" }, 429); + if (!rateLimit.allowed) { + return jsonResponse( + req, + { + error: "Rate limit exceeded", + retryAfterSeconds: rateLimit.retryAfterSeconds, + }, + 429, + { + "retry-after": String(rateLimit.retryAfterSeconds), + }, + ); } if (req.method === "OPTIONS") { @@ -3132,11 +4323,25 @@ const server = Bun.serve({ cycleId: streamState.cycle?.cycleId ?? null, phase: streamState.cycle?.phase ?? null, lastUpdatedAt: streamLastUpdatedAt, - sourceUrl: STREAM_STATE_SOURCE_URL - ? sanitizeUrlForStatus(STREAM_STATE_SOURCE_URL) + sourceUrl: BET_SYNC_SOURCE_EVENTS_URL + ? sanitizeUrlForStatus(BET_SYNC_SOURCE_EVENTS_URL) + : ACTIVE_STREAM_STATE_SOURCE_URL + ? sanitizeUrlForStatus(ACTIVE_STREAM_STATE_SOURCE_URL) : null, lastSourcePollAt: streamLastSourcePollAt, - lastSourceError: streamLastSourceError, + lastSourceError: betSyncLastError ?? streamLastSourceError, + betSync: { + enabled: Boolean(BET_SYNC_SOURCE_EVENTS_URL), + replayMode: betSyncReplayMode, + sourceEpoch: betSyncSourceEpoch, + sourceLatestSeq: betSyncSourceLatestSeq, + oldestReplaySeq: betSyncOldestReplaySeq, + lastAppliedSeq: betSyncLastAppliedSeq, + connectedAt: betSyncConnectedAt, + lastEventReceivedAt: betSyncLastEventReceivedAt, + lastAppliedAt: betSyncLastAppliedAt, + lastError: betSyncLastError, + }, sseClients: connectedSseCount(), }, parsers, @@ -3184,7 +4389,18 @@ const server = Bun.serve({ } if (req.method === "GET" && url.pathname === "/api/streaming/state") { - return jsonResponse(req, streamState, 200, { + return jsonResponse( + req, + toStreamStateFromCanonicalSession(canonicalStreamSession), + 200, + { + "cache-control": "no-store", + }, + ); + } + + if (req.method === "GET" && url.pathname === "/api/streaming/session") { + return jsonResponse(req, canonicalStreamSession, 200, { "cache-control": "no-store", }); } @@ -3254,6 +4470,37 @@ const server = Bun.serve({ return new Response(stream, { status: 200, headers }); } + if ( + req.method === "GET" && + url.pathname === "/api/streaming/session/events" + ) { + const stream = new ReadableStream({ + start(controller) { + sessionSseClients.add(controller); + sendSse( + controller, + "reset", + canonicalStreamSession.seq, + canonicalStreamSession, + ); + controller.enqueue(encoder.encode(": connected\n\n")); + }, + cancel(reason) { + void reason; + }, + }); + + const headers = new Headers({ + "content-type": "text/event-stream; charset=utf-8", + "cache-control": + "no-store, no-cache, must-revalidate, proxy-revalidate", + connection: "keep-alive", + ...securityHeaders(), + }); + applyCors(req, headers); + return new Response(stream, { status: 200, headers }); + } + if ( req.method === "POST" && url.pathname === "/api/streaming/state/publish" @@ -3436,9 +4683,14 @@ setInterval(() => { } }, 20_000); -if (STREAM_STATE_SOURCE_URL) { +if (BET_SYNC_SOURCE_EVENTS_URL) { + console.log( + `[${nowIso()}] [bet-sync] consuming ${BET_SYNC_SOURCE_EVENTS_URL}`, + ); + startBetSyncConsumer(); +} else if (ACTIVE_STREAM_STATE_SOURCE_URL) { console.log( - `[${nowIso()}] [stream] polling source ${STREAM_STATE_SOURCE_URL}`, + `[${nowIso()}] [stream] polling source ${ACTIVE_STREAM_STATE_SOURCE_URL}`, ); setInterval(() => { void pollStreamStateSource(); @@ -3446,6 +4698,16 @@ if (STREAM_STATE_SOURCE_URL) { void pollStreamStateSource(); } +if (STREAM_RENDERER_HEALTH_URL) { + console.log( + `[${nowIso()}] [renderer-health] polling ${STREAM_RENDERER_HEALTH_URL}`, + ); + setInterval(() => { + void pollRendererHealthSource(); + }, STREAM_RENDERER_HEALTH_POLL_MS); + void pollRendererHealthSource(); +} + setInterval(() => { void pollContractParsers(); }, CONTRACT_POLL_MS); diff --git a/packages/hyperbet-evm/app/public/hls-player.html b/packages/hyperbet-evm/app/public/hls-player.html new file mode 100644 index 00000000..8485967e --- /dev/null +++ b/packages/hyperbet-evm/app/public/hls-player.html @@ -0,0 +1,465 @@ + + + + + + HLS Player + + + + +
+
+ + + + diff --git a/packages/hyperbet-solana/app/public/hls-player.html b/packages/hyperbet-solana/app/public/hls-player.html new file mode 100644 index 00000000..8485967e --- /dev/null +++ b/packages/hyperbet-solana/app/public/hls-player.html @@ -0,0 +1,465 @@ + + + + + + HLS Player + + + + +
+
+ + + + diff --git a/packages/hyperbet-solana/app/src/App.tsx b/packages/hyperbet-solana/app/src/App.tsx index 71eaf522..7f46c448 100644 --- a/packages/hyperbet-solana/app/src/App.tsx +++ b/packages/hyperbet-solana/app/src/App.tsx @@ -32,9 +32,14 @@ import { normalizePredictionMarketDuelKeyHex, usePredictionMarketLifecycle, } from "@hyperbet/ui/lib/predictionMarkets"; +import { + describeCanonicalRendererDegradedReason, + selectBetSurfaceStreamUrl, +} from "@hyperbet/ui/lib/streamSession"; import { useAppConnection, useAppWallet, useAppWalletModal } from "./lib/appWallet"; import { StreamPlayer } from "@hyperbet/ui/components/StreamPlayer"; import { PointsDisplay } from "@hyperbet/ui/components/PointsDisplay"; +import { useCanonicalStreamSession } from "@hyperbet/ui/spectator/useCanonicalStreamSession"; import type { SolanaClobMarketSnapshot } from "@hyperbet/ui/components/SolanaClobPanel"; import { getDuelStateDecoder } from "./generated/fight-oracle/accounts"; import { @@ -49,6 +54,7 @@ import { FIGHT_ORACLE_PROGRAM_ID } from "./lib/programIds"; import { useStreamingState } from "./spectator/useStreamingState"; import { useDuelContext } from "@hyperbet/ui/spectator/useDuelContext"; import type { LeaderboardEntry } from "./spectator/types"; +import { useMeasuredContentBox } from "@hyperbet/ui/lib/useMeasuredContentBox"; import { useResizePanel, useIsMobile } from "@hyperbet/ui/lib/useResizePanel"; import { ResizeHandle } from "@hyperbet/ui/components/ResizeHandle"; import { @@ -57,7 +63,6 @@ import { XAxis, YAxis, Tooltip, - ResponsiveContainer, ReferenceLine, } from "recharts"; @@ -745,8 +750,16 @@ export function App() { >("leaderboard"); const appRootRef = useRef(null); const bettingDockInnerRef = useRef(null); + const chartContainerRef = useRef(null); + const chartSize = useMeasuredContentBox(chartContainerRef, !isMobile, 2); const { state: streamingState } = useStreamingState(); + const { + session: canonicalStreamSession, + playback: canonicalPlayback, + rendererHealth: canonicalRendererHealth, + authorityHealth: canonicalAuthorityHealth, + } = useCanonicalStreamSession(); const { context: duelContext } = useDuelContext(); const liveCycle = streamingState?.cycle ?? null; const { market: lifecycleMarket } = usePredictionMarketLifecycle("solana"); @@ -762,7 +775,44 @@ export function App() { [isE2eMode], ); const streamSources = STREAM_URLS; - const activeStreamUrl = streamSources[streamSourceIndex] ?? ""; + const lifecycleDuelKey = normalizePredictionMarketDuelKeyHex( + lifecycleMarket?.duelKey ?? null, + ); + const { activeStreamUrl, preloadStreamUrl } = selectBetSurfaceStreamUrl({ + allowFallbackWhenSessionUnavailable: false, + authorityHealth: canonicalAuthorityHealth, + fallbackStreamIndex: streamSourceIndex, + fallbackStreamSources: streamSources, + lifecycleDuelId: lifecycleMarket?.duelId ?? null, + lifecycleDuelKey, + rendererReady: canonicalRendererHealth?.ready ?? null, + session: canonicalStreamSession, + }); + const mountedStreamUrl = activeStreamUrl || preloadStreamUrl; + const streamPlaceholderMessage = useMemo(() => { + if (streamSources.length === 0) { + return "Invalid live stream configuration. A tokenized Hyperscapes /stream URL is required."; + } + if (!canonicalStreamSession) { + return "Connecting to live session..."; + } + if (canonicalAuthorityHealth?.ready === false) { + return "Stream authority unavailable. Waiting for session state."; + } + if (canonicalRendererHealth?.ready === false) { + return describeCanonicalRendererDegradedReason( + canonicalRendererHealth.degradedReason, + "Waiting for stream...", + ); + } + return "Waiting for stream..."; + }, [ + canonicalAuthorityHealth?.ready, + canonicalRendererHealth?.degradedReason, + canonicalRendererHealth?.ready, + canonicalStreamSession, + streamSources.length, + ]); const switchToBackupStream = useCallback(() => { setStreamSourceIndex((current) => @@ -1681,10 +1731,10 @@ export function App() { {/* Game Viewport */}
- {activeStreamUrl ? ( + {mountedStreamUrl ? ( <> -
- - {streamSources.length > 1 && ( + {activeStreamUrl ? ( +
- )} -
+ {streamSources.length > 1 && ( + + )} +
+ ) : null} + {!activeStreamUrl ? ( +
+
+ + {streamPlaceholderMessage} + +
+ ) : null} ) : (
- - Waiting for stream… - + {streamPlaceholderMessage}
)}
{/* Odds Chart */} -
-
- - - -
-
- - {(effYesPercent / 100).toFixed(1)} - -
-
- - - { - const d = new Date(v); - return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; - }} - /> - `${v}%`} - /> - - active && payload?.length ? ( -
- {payload[0].value}% -
- ) : null - } - /> - - -
-
+ {!isMobile && ( +
+
+ + + +
+
+ + {(effYesPercent / 100).toFixed(1)} + +
+
+ {chartSize ? ( + + { + const d = new Date(v); + return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; + }} + /> + `${v}%`} + /> + + active && payload?.length ? ( +
+ {payload[0].value}% +
+ ) : null + } + /> + + +
+ ) : null} +
-
+ )}
{ if (envStreamSources.length > 0) { - return uniqueList(envStreamSources); + return sanitizeResolvedStreamSources(envStreamSources); } const envFallbackUrl = readEnvString("VITE_STREAM_FALLBACK_URL"); const fallbackUrl = @@ -314,8 +315,10 @@ const resolvedStreamSources = (() => { (defaultPrimaryStreamUrl && !suppressDefaultStreamFallback ? DEFAULT_STREAM_FALLBACK_URL : ""); - return uniqueList([defaultPrimaryStreamUrl, fallbackUrl ?? ""]).filter( - (value) => value.length > 0, + return sanitizeResolvedStreamSources( + uniqueList([defaultPrimaryStreamUrl, fallbackUrl ?? ""]).filter( + (value) => value.length > 0, + ), ); })(); const resolvedStreamUrl = resolvedStreamSources[0] ?? ""; diff --git a/packages/hyperbet-solana/keeper/src/betSync.ts b/packages/hyperbet-solana/keeper/src/betSync.ts new file mode 100644 index 00000000..a5d75604 --- /dev/null +++ b/packages/hyperbet-solana/keeper/src/betSync.ts @@ -0,0 +1,596 @@ +import type { + PredictionMarketLifecycleRecord, + PredictionMarketLifecycleStatus, + PredictionMarketWinner, +} from "../../../hyperbet-chain-registry/src/index"; +import { + normalizePredictionMarketTimestamp, + normalizePredictionMarketWinner, +} from "../../../hyperbet-chain-registry/src/index"; + +type JsonRecord = Record; + +export type BetSyncRendererHealth = { + ready: boolean; + degradedReason: string | null; + updatedAt: number | null; +}; + +export type BetSyncHlsManifest = { + updatedAt: number | null; + mediaSequence: number | null; +}; + +export type BetSyncRendererMetrics = { + captureFps: number | null; + encodeFps: number | null; + droppedFrames: number | null; + renderTick: number | null; + duelStateTick: number | null; + latestFrameAt: number | null; + latestRenderTickAt: number | null; + latestDuelStateTickAt: number | null; + latestVisualChangeAt: number | null; + visualChangeAgeMs: number | null; + hlsManifest: BetSyncHlsManifest | null; +}; + +export type BetSyncDelivery = { + mode: "self_hls" | "external_hls"; + provider: string | null; + playbackUrl: string | null; + hlsUrl: string | null; + llhlsUrl: string | null; + ingestUrl: string | null; +}; + +export type BetSyncEvent = { + schemaVersion: number; + sourceEpoch: number; + seq: number; + emittedAt: number; + duelId: string | null; + duelKey: string | null; + phase: string | null; + phaseVersion: number | null; + betOpenTime: number | null; + betCloseTime: number | null; + fightStartTime: number | null; + duelEndTime: number | null; + winnerId: string | null; + winnerName: string | null; + winReason: string | null; + seed: string | null; + replayHash: string | null; + agent1: JsonRecord | null; + agent2: JsonRecord | null; + arenaPositions: JsonRecord | null; + leaderboard: JsonRecord[]; + cameraTarget: string | null; + rendererHealth: BetSyncRendererHealth | null; + rendererMetrics: BetSyncRendererMetrics | null; + delivery: BetSyncDelivery | null; +}; + +export type BetSyncBootstrapState = { + sourceEpoch: number; + latestSeq: number; + oldestReplaySeq: number | null; + latestEvent: BetSyncEvent | null; +}; + +export type StreamState = { + type: "STREAMING_STATE_UPDATE"; + cycle: JsonRecord; + leaderboard: JsonRecord[]; + cameraTarget: string | null; + seq: number; + emittedAt: number; +}; + +export type PredictionMarketsDuelSnapshot = { + duelKey: string | null; + duelId: string | null; + phase: string | null; + winner: PredictionMarketWinner; + betCloseTime: number | null; + agent1Name: string | null; + agent2Name: string | null; +}; + +export type PredictionMarketsSurface = { + duel: PredictionMarketsDuelSnapshot; + markets: PredictionMarketLifecycleRecord[]; + updatedAt: number | null; +}; + +export type PredictionMarketsOverviewResponse = { + updatedAt: number | null; + live: PredictionMarketsSurface | null; + recentSettlement: PredictionMarketsSurface | null; +}; + +export type BetSyncReplayMode = "bootstrap" | "replay" | "reset" | "live"; + +function asRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" ? (value as JsonRecord) : null; +} + +function asFiniteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function normalizeDuelKey(value: unknown): string | null { + const raw = asString(value); + if (!raw) return null; + const normalized = raw.replace(/^0x/i, "").trim().toLowerCase(); + return /^[0-9a-f]{64}$/.test(normalized) ? normalized : null; +} + +const PREDICTION_MARKET_STATUS_RANK: Record< + PredictionMarketLifecycleStatus, + number +> = { + UNKNOWN: -1, + PENDING: 0, + OPEN: 1, + LOCKED: 2, + PROPOSED: 3, + CHALLENGED: 4, + RESOLVED: 5, + CANCELLED: 5, +}; + +function statusRank(status: PredictionMarketLifecycleStatus): number { + return PREDICTION_MARKET_STATUS_RANK[status] ?? -1; +} + +function preferPreviousMarket( + previous: PredictionMarketLifecycleRecord, + next: PredictionMarketLifecycleRecord, +): boolean { + const previousRank = statusRank(previous.lifecycleStatus); + const nextRank = statusRank(next.lifecycleStatus); + if (nextRank < previousRank) { + return true; + } + if ( + nextRank === previousRank && + previous.winner !== "NONE" && + next.winner === "NONE" + ) { + return true; + } + return false; +} + +function mergeMarketRecord( + previous: PredictionMarketLifecycleRecord, + next: PredictionMarketLifecycleRecord, +): PredictionMarketLifecycleRecord { + const keepPrevious = preferPreviousMarket(previous, next); + const preferred = keepPrevious ? previous : next; + const fallback = keepPrevious ? next : previous; + + return { + ...fallback, + ...preferred, + duelKey: preferred.duelKey ?? fallback.duelKey ?? null, + duelId: preferred.duelId ?? fallback.duelId ?? null, + betCloseTime: preferred.betCloseTime ?? fallback.betCloseTime ?? null, + winner: preferred.winner !== "NONE" ? preferred.winner : fallback.winner, + syncedAt: + preferred.syncedAt != null + ? Math.max(preferred.syncedAt, fallback.syncedAt ?? preferred.syncedAt) + : (fallback.syncedAt ?? null), + metadata: + previous.metadata || next.metadata + ? { + ...(keepPrevious ? next.metadata ?? {} : previous.metadata ?? {}), + ...(keepPrevious ? previous.metadata ?? {} : next.metadata ?? {}), + } + : undefined, + }; +} + +function mergeDuelSnapshot( + previous: PredictionMarketsDuelSnapshot, + next: PredictionMarketsDuelSnapshot, +): PredictionMarketsDuelSnapshot { + const nextPhase = + next.phase === "IDLE" && previous.phase && previous.phase !== "IDLE" + ? previous.phase + : (next.phase ?? previous.phase ?? null); + + return { + duelKey: next.duelKey ?? previous.duelKey ?? null, + duelId: next.duelId ?? previous.duelId ?? null, + phase: nextPhase, + winner: next.winner !== "NONE" ? next.winner : previous.winner, + betCloseTime: next.betCloseTime ?? previous.betCloseTime ?? null, + agent1Name: next.agent1Name ?? previous.agent1Name ?? null, + agent2Name: next.agent2Name ?? previous.agent2Name ?? null, + }; +} + +function normalizeRendererHealth(value: unknown): BetSyncRendererHealth | null { + const candidate = asRecord(value); + if (!candidate) return null; + return { + ready: candidate.ready === true, + degradedReason: asString(candidate.degradedReason), + updatedAt: normalizePredictionMarketTimestamp(candidate.updatedAt), + }; +} + +function normalizeHlsManifest(value: unknown): BetSyncHlsManifest | null { + const candidate = asRecord(value); + if (!candidate) return null; + return { + updatedAt: normalizePredictionMarketTimestamp(candidate.updatedAt), + mediaSequence: asFiniteNumber(candidate.mediaSequence), + }; +} + +function normalizeRendererMetrics( + value: unknown, +): BetSyncRendererMetrics | null { + const candidate = asRecord(value); + if (!candidate) return null; + return { + captureFps: asFiniteNumber(candidate.captureFps), + encodeFps: asFiniteNumber(candidate.encodeFps), + droppedFrames: asFiniteNumber(candidate.droppedFrames), + renderTick: asFiniteNumber(candidate.renderTick), + duelStateTick: asFiniteNumber(candidate.duelStateTick), + latestFrameAt: normalizePredictionMarketTimestamp(candidate.latestFrameAt), + latestRenderTickAt: normalizePredictionMarketTimestamp( + candidate.latestRenderTickAt, + ), + latestDuelStateTickAt: normalizePredictionMarketTimestamp( + candidate.latestDuelStateTickAt, + ), + latestVisualChangeAt: normalizePredictionMarketTimestamp( + candidate.latestVisualChangeAt, + ), + visualChangeAgeMs: asFiniteNumber(candidate.visualChangeAgeMs), + hlsManifest: normalizeHlsManifest(candidate.hlsManifest), + }; +} + +function normalizeDelivery(value: unknown): BetSyncDelivery | null { + const candidate = asRecord(value); + if (!candidate) return null; + const mode = asString(candidate.mode); + if (mode !== "self_hls" && mode !== "external_hls") { + return null; + } + return { + mode, + provider: asString(candidate.provider), + playbackUrl: asString(candidate.playbackUrl), + hlsUrl: asString(candidate.hlsUrl), + llhlsUrl: asString(candidate.llhlsUrl), + ingestUrl: asString(candidate.ingestUrl), + }; +} + +export function parseBetSyncEvent(payload: unknown): BetSyncEvent | null { + const candidate = asRecord(payload); + if (!candidate) return null; + + const seq = asFiniteNumber(candidate.seq); + const emittedAt = normalizePredictionMarketTimestamp(candidate.emittedAt); + const sourceEpoch = asFiniteNumber(candidate.sourceEpoch); + + if (seq == null || emittedAt == null || sourceEpoch == null) { + return null; + } + + return { + schemaVersion: asFiniteNumber(candidate.schemaVersion) ?? 1, + sourceEpoch, + seq, + emittedAt, + duelId: asString(candidate.duelId), + duelKey: normalizeDuelKey(candidate.duelKey), + phase: asString(candidate.phase), + phaseVersion: asFiniteNumber(candidate.phaseVersion), + betOpenTime: normalizePredictionMarketTimestamp(candidate.betOpenTime), + betCloseTime: normalizePredictionMarketTimestamp(candidate.betCloseTime), + fightStartTime: normalizePredictionMarketTimestamp(candidate.fightStartTime), + duelEndTime: normalizePredictionMarketTimestamp(candidate.duelEndTime), + winnerId: asString(candidate.winnerId), + winnerName: asString(candidate.winnerName), + winReason: asString(candidate.winReason), + seed: asString(candidate.seed), + replayHash: asString(candidate.replayHash), + agent1: asRecord(candidate.agent1), + agent2: asRecord(candidate.agent2), + arenaPositions: asRecord(candidate.arenaPositions), + leaderboard: Array.isArray(candidate.leaderboard) + ? candidate.leaderboard + .map((entry) => asRecord(entry)) + .filter((entry): entry is JsonRecord => entry !== null) + : [], + cameraTarget: asString(candidate.cameraTarget), + rendererHealth: normalizeRendererHealth(candidate.rendererHealth), + rendererMetrics: normalizeRendererMetrics(candidate.rendererMetrics), + delivery: normalizeDelivery(candidate.delivery), + }; +} + +export function parseBetSyncBootstrapState( + payload: unknown, +): BetSyncBootstrapState | null { + const candidate = asRecord(payload); + if (!candidate) return null; + + const replay = asRecord(candidate.replay); + const sourceEpoch = + asFiniteNumber(candidate.sourceEpoch) ?? + asFiniteNumber(replay?.sourceEpoch); + const latestSeq = + asFiniteNumber(candidate.latestSeq) ?? + asFiniteNumber(replay?.latestSeq) ?? + asFiniteNumber(candidate.seq); + if (sourceEpoch == null || latestSeq == null) { + return null; + } + + return { + sourceEpoch, + latestSeq, + oldestReplaySeq: + asFiniteNumber(candidate.oldestReplaySeq) ?? + asFiniteNumber(replay?.oldestSeq), + latestEvent: + parseBetSyncEvent(candidate.latestEvent) ?? parseBetSyncEvent(candidate), + }; +} + +export function toStreamStateFromBetSyncEvent(event: BetSyncEvent): StreamState { + return { + type: "STREAMING_STATE_UPDATE", + cycle: { + cycleId: event.duelId ?? `bet-sync-${event.sourceEpoch}-${event.seq}`, + duelId: event.duelId, + duelKey: event.duelKey, + duelKeyHex: event.duelKey ? `0x${event.duelKey}` : null, + phase: event.phase ?? "IDLE", + phaseVersion: event.phaseVersion, + betOpenTime: event.betOpenTime, + betCloseTime: event.betCloseTime, + fightStartTime: event.fightStartTime, + duelEndTime: event.duelEndTime, + winnerId: event.winnerId, + winnerName: event.winnerName, + winReason: event.winReason, + seed: event.seed, + replayHash: event.replayHash, + agent1: event.agent1, + agent2: event.agent2, + arenaPositions: event.arenaPositions, + rendererHealth: event.rendererHealth, + }, + leaderboard: event.leaderboard, + cameraTarget: event.cameraTarget, + seq: event.seq, + emittedAt: event.emittedAt, + }; +} + +export function parsePredictionMarketsSurface( + payload: unknown, +): PredictionMarketsSurface | null { + const candidate = asRecord(payload); + const duel = asRecord(candidate?.duel); + if (!candidate || !duel || !Array.isArray(candidate.markets)) { + return null; + } + + return { + duel: { + duelKey: normalizeDuelKey(duel.duelKey), + duelId: asString(duel.duelId), + phase: asString(duel.phase), + winner: normalizePredictionMarketWinner(duel.winner), + betCloseTime: normalizePredictionMarketTimestamp(duel.betCloseTime), + agent1Name: asString(duel.agent1Name), + agent2Name: asString(duel.agent2Name), + }, + markets: candidate.markets.filter( + (market): market is PredictionMarketLifecycleRecord => + Boolean(market) && typeof market === "object", + ) as PredictionMarketLifecycleRecord[], + updatedAt: normalizePredictionMarketTimestamp(candidate.updatedAt), + }; +} + +export function parsePredictionMarketsOverview( + payload: unknown, +): PredictionMarketsOverviewResponse | null { + const candidate = asRecord(payload); + if (!candidate) return null; + return { + updatedAt: normalizePredictionMarketTimestamp(candidate.updatedAt), + live: parsePredictionMarketsSurface(candidate.live), + recentSettlement: parsePredictionMarketsSurface(candidate.recentSettlement), + }; +} + +export function hasMeaningfulSurface( + surface: PredictionMarketsSurface | null | undefined, +): boolean { + if (!surface) return false; + return Boolean( + surface.duel.duelKey || surface.duel.duelId || surface.markets.length, + ); +} + +export function sameDuelIdentity( + left: PredictionMarketsSurface | null | undefined, + right: PredictionMarketsSurface | null | undefined, +): boolean { + if (!left || !right) return false; + if (left.duel.duelKey && right.duel.duelKey) { + return left.duel.duelKey === right.duel.duelKey; + } + if (left.duel.duelId && right.duel.duelId) { + return left.duel.duelId === right.duel.duelId; + } + return false; +} + +export function mergePredictionMarketsSurface( + previous: PredictionMarketsSurface | null, + next: PredictionMarketsSurface | null, +): PredictionMarketsSurface | null { + if (!next) return previous; + if (!previous) return next; + + const byChain = new Map(); + for (const market of previous.markets) { + byChain.set(market.chainKey, market); + } + for (const market of next.markets) { + const existing = byChain.get(market.chainKey); + byChain.set( + market.chainKey, + existing ? mergeMarketRecord(existing, market) : market, + ); + } + + return { + duel: mergeDuelSnapshot(previous.duel, next.duel), + markets: Array.from(byChain.values()), + updatedAt: next.updatedAt, + }; +} + +export function rollPredictionMarketsOverview( + previous: PredictionMarketsOverviewResponse | null, + live: PredictionMarketsSurface | null, + updatedAt: number, +): PredictionMarketsOverviewResponse { + const nextLive = live; + let recentSettlement = previous?.recentSettlement ?? null; + + if ( + hasMeaningfulSurface(previous?.live) && + hasMeaningfulSurface(nextLive) && + !sameDuelIdentity(previous?.live, nextLive) + ) { + recentSettlement = previous?.live ?? null; + } + + return { + updatedAt, + live: nextLive, + recentSettlement, + }; +} + +export function selectBetSyncResumeSeq(params: { + lastAppliedSeq: number; +}): number { + return Math.max(0, params.lastAppliedSeq); +} + +export function selectBetSyncReplayUntilSeq(params: { + resumeSeq: number; + latestSeq: number | null; +}): number | null { + const resumeSeq = Math.max(0, params.resumeSeq); + if (resumeSeq <= 0 || params.latestSeq == null) { + return null; + } + return params.latestSeq > resumeSeq ? params.latestSeq : null; +} + +export function resolveBetSyncBootstrapCursor(params: { + previousSourceEpoch: number | null; + nextSourceEpoch: number; + previousLastAppliedSeq: number; + latestSeq: number | null; +}): { + sourceEpochChanged: boolean; + rebasedLastAppliedSeq: number; + replayUntilSeq: number | null; + replayMode: BetSyncReplayMode; +} { + const sourceEpochChanged = + params.previousSourceEpoch != null && + params.previousSourceEpoch !== params.nextSourceEpoch; + const rebasedLastAppliedSeq = sourceEpochChanged + ? 0 + : Math.max(0, params.previousLastAppliedSeq); + const replayUntilSeq = selectBetSyncReplayUntilSeq({ + resumeSeq: selectBetSyncResumeSeq({ + lastAppliedSeq: rebasedLastAppliedSeq, + }), + latestSeq: params.latestSeq, + }); + + return { + sourceEpochChanged, + rebasedLastAppliedSeq, + replayUntilSeq, + replayMode: + rebasedLastAppliedSeq > 0 && replayUntilSeq != null ? "replay" : "live", + }; +} + +export function isBetSyncEventStaleAfterSourceReset(params: { + sourceEpochChanged: boolean; + currentStreamEmittedAt: number | null; + eventEmittedAt: number; + toleranceMs: number; +}): boolean { + if (!params.sourceEpochChanged || params.currentStreamEmittedAt == null) { + return false; + } + return ( + params.eventEmittedAt + Math.max(0, params.toleranceMs) < + params.currentStreamEmittedAt + ); +} + +export function resolveBetSyncReplayMode(params: { + eventName: string; + eventSeq: number; + replayUntilSeq: number | null; + sourceEpochChanged: boolean; +}): { + replayMode: BetSyncReplayMode; + replayUntilSeq: number | null; +} { + if (params.sourceEpochChanged || params.eventName === "reset") { + return { + replayMode: "reset", + replayUntilSeq: null, + }; + } + + if (params.replayUntilSeq != null) { + if (params.eventSeq < params.replayUntilSeq) { + return { + replayMode: "replay", + replayUntilSeq: params.replayUntilSeq, + }; + } + return { + replayMode: "live", + replayUntilSeq: null, + }; + } + + return { + replayMode: "live", + replayUntilSeq: null, + }; +} diff --git a/packages/hyperbet-solana/keeper/src/service.ts b/packages/hyperbet-solana/keeper/src/service.ts index 768af283..03e4e9db 100644 --- a/packages/hyperbet-solana/keeper/src/service.ts +++ b/packages/hyperbet-solana/keeper/src/service.ts @@ -48,6 +48,7 @@ import { import type { FightOracle } from "./idl/fight_oracle"; import type { GoldClobMarket } from "./idl/gold_clob_market"; import { + type DbBetSyncCheckpoint, deleteIdentityMembers, loadAll, loadPerpsMarkets, @@ -63,7 +64,20 @@ import { saveReferral, saveInvitedWallet, saveReferralFees, + saveBetSyncCheckpoint, } from "./db"; +import { + isBetSyncEventStaleAfterSourceReset, + parseBetSyncBootstrapState, + parseBetSyncEvent, + resolveBetSyncBootstrapCursor, + resolveBetSyncReplayMode, + selectBetSyncReplayUntilSeq, + selectBetSyncResumeSeq, + toStreamStateFromBetSyncEvent, + type BetSyncEvent, + type BetSyncReplayMode, +} from "./betSync"; import { buildKeeperBotChildEnv } from "./keeperBot"; import { modelMarketIdFromCharacterId } from "./modelMarkets"; import { @@ -80,6 +94,71 @@ type StreamState = { emittedAt: number; }; +type CanonicalStreamHealth = { + ready: boolean; + degradedReason: string | null; + updatedAt: number | null; +}; + +type CanonicalStreamPlayback = { + url: string | null; + kind: string | null; + renderSessionId: string | null; + presentationDelayMs: number; +}; + +type CanonicalHlsManifest = { + updatedAt: number | null; + mediaSequence: number | null; +}; + +type CanonicalRendererMetrics = { + captureFps: number | null; + encodeFps: number | null; + droppedFrames: number | null; + renderTick: number | null; + duelStateTick: number | null; + latestFrameAt: number | null; + latestRenderTickAt: number | null; + latestDuelStateTickAt: number | null; + latestVisualChangeAt: number | null; + visualChangeAgeMs: number | null; + hlsManifest: CanonicalHlsManifest | null; +}; + +type CanonicalStreamDelivery = { + mode: "self_hls" | "external_hls"; + provider: string | null; + playbackUrl: string | null; + hlsUrl: string | null; + llhlsUrl: string | null; + ingestUrl: string | null; +}; + +type CanonicalStreamSession = { + schemaVersion: number; + sourceEpoch: number | null; + seq: number; + emittedAt: number; + duelId: string | null; + duelKey: string | null; + phase: string | null; + phaseVersion: number | null; + cycle: Record; + leaderboard: unknown[]; + cameraTarget: string | null; + playback: CanonicalStreamPlayback | null; + rendererHealth: CanonicalStreamHealth | null; + rendererMetrics: CanonicalRendererMetrics | null; + delivery: CanonicalStreamDelivery | null; + authorityHealth: CanonicalStreamHealth; + status: { + authority: CanonicalStreamHealth; + renderer: CanonicalStreamHealth | null; + delivery: CanonicalStreamDelivery | null; + }; +}; + type BetRecord = { id: string; bettorWallet: string; @@ -143,6 +222,7 @@ type JsonRpcRequestPayload = Record & { }; const encoder = new TextEncoder(); +const decoder = new TextDecoder(); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const keeperRoot = path.resolve(__dirname, ".."); const KEEPER_BOT_HEALTH_FILE = ( @@ -213,6 +293,18 @@ function readEnvBoolean(name: string, fallback: boolean): boolean { return raw === "1" || raw === "true" || raw === "yes" || raw === "on"; } +function deriveBetSyncStateUrl(eventsUrl: string): string { + if (!eventsUrl) return ""; + try { + const url = new URL(eventsUrl); + url.pathname = url.pathname.replace(/\/events$/, "/state"); + url.search = ""; + return url.toString(); + } catch { + return ""; + } +} + const PORT = Number(process.env.PORT || 8080); const ARENA_WRITE_KEY = process.env.ARENA_EXTERNAL_BET_WRITE_KEY?.trim() || ""; const STREAM_PUBLISH_KEY = @@ -227,6 +319,18 @@ const STREAM_STATE_SOURCE_URL = process.env.STREAM_STATE_SOURCE_URL?.trim() || ""; const STREAM_STATE_SOURCE_BEARER_TOKEN = process.env.STREAM_STATE_SOURCE_BEARER_TOKEN?.trim() || ""; +const BET_SYNC_SOURCE_EVENTS_URL = + process.env.BET_SYNC_SOURCE_EVENTS_URL?.trim() || ""; +const BET_SYNC_SOURCE_STATE_URL = + process.env.BET_SYNC_SOURCE_STATE_URL?.trim() || + deriveBetSyncStateUrl(BET_SYNC_SOURCE_EVENTS_URL); +const BET_SYNC_SOURCE_BEARER_TOKEN = + process.env.BET_SYNC_SOURCE_BEARER_TOKEN?.trim() || + STREAM_STATE_SOURCE_BEARER_TOKEN; +const ACTIVE_STREAM_STATE_SOURCE_URL = + BET_SYNC_SOURCE_STATE_URL || STREAM_STATE_SOURCE_URL; +const ACTIVE_STREAM_SOURCE_BEARER_TOKEN = + BET_SYNC_SOURCE_BEARER_TOKEN || STREAM_STATE_SOURCE_BEARER_TOKEN; const STREAM_STATE_POLL_MS = Math.max( 1_000, Number(process.env.STREAM_STATE_POLL_MS || 2_000), @@ -239,6 +343,58 @@ const STREAM_STATE_SOURCE_MAX_BACKOFF_MS = Math.max( STREAM_STATE_POLL_MS, Number(process.env.STREAM_STATE_SOURCE_MAX_BACKOFF_MS || 30_000), ); +const BET_SYNC_CONNECT_TIMEOUT_MS = Math.max( + 1_000, + Number(process.env.BET_SYNC_CONNECT_TIMEOUT_MS || 10_000), +); +const BET_SYNC_RECONNECT_MIN_MS = Math.max( + 500, + Number(process.env.BET_SYNC_RECONNECT_MIN_MS || 1_000), +); +const BET_SYNC_RECONNECT_MAX_MS = Math.max( + BET_SYNC_RECONNECT_MIN_MS, + Number(process.env.BET_SYNC_RECONNECT_MAX_MS || 30_000), +); +const BET_SYNC_STALE_EVENT_TOLERANCE_MS = Math.max( + 0, + Number(process.env.BET_SYNC_STALE_EVENT_TOLERANCE_MS || 5_000), +); +const STREAM_DELIVERY_MODE = ( + process.env.STREAM_DELIVERY_MODE?.trim().toLowerCase() || "" +) as "self_hls" | "external_hls" | ""; +const STREAM_DELIVERY_PROVIDER = + process.env.STREAM_DELIVERY_PROVIDER?.trim() || ""; +const STREAM_INGEST_RTMPS_URL = + process.env.STREAM_INGEST_RTMPS_URL?.trim() || ""; +const STREAM_PLAYBACK_HLS_URL = + process.env.STREAM_PLAYBACK_HLS_URL?.trim() || ""; +const STREAM_PLAYBACK_LLHLS_URL = + process.env.STREAM_PLAYBACK_LLHLS_URL?.trim() || ""; +const STREAM_PLAYBACK_KIND = + process.env.STREAM_PLAYBACK_KIND?.trim().toLowerCase() || "hls"; +const STREAM_PRESENTATION_DELAY_MS = Math.max( + 0, + Number(process.env.STREAM_PRESENTATION_DELAY_MS || 0), +); +const STREAM_PLAYBACK_URL = + process.env.STREAM_PLAYBACK_URL?.trim() || + deriveDefaultStreamPlaybackUrl(ACTIVE_STREAM_STATE_SOURCE_URL); +const STREAM_RENDERER_HEALTH_URL = + process.env.STREAM_RENDERER_HEALTH_URL?.trim() || ""; +const STREAM_RENDERER_HEALTH_BEARER_TOKEN = + process.env.STREAM_RENDERER_HEALTH_BEARER_TOKEN?.trim() || ""; +const STREAM_RENDERER_HEALTH_POLL_MS = Math.max( + 1_000, + Number(process.env.STREAM_RENDERER_HEALTH_POLL_MS || 2_000), +); +const STREAM_RENDERER_HEALTH_TIMEOUT_MS = Math.max( + 500, + Number(process.env.STREAM_RENDERER_HEALTH_TIMEOUT_MS || 3_000), +); +const STREAM_RENDERER_HLS_FRESHNESS_MS = Math.max( + STREAM_RENDERER_HEALTH_POLL_MS, + Number(process.env.STREAM_RENDERER_HLS_FRESHNESS_MS || 15_000), +); const CONTRACT_POLL_MS = Math.max( 5_000, Number(process.env.CONTRACT_POLL_MS || 15_000), @@ -341,8 +497,25 @@ let streamLastSourceError: string | null = null; let streamSourcePollInFlight = false; let streamSourceConsecutiveFailures = 0; let streamSourceBackoffUntil = 0; +let streamRendererHealthOverride: CanonicalStreamHealth | null = null; +let streamRendererMetricsOverride: CanonicalRendererMetrics | null = null; +let streamDeliveryOverride: CanonicalStreamDelivery | null = null; +let streamRendererHealthPollInFlight = false; +let canonicalStreamSession: CanonicalStreamSession; +let betSyncSourceLatestSeq = 0; +let betSyncOldestReplaySeq: number | null = null; +let betSyncLastEventReceivedAt: number | null = null; +let betSyncLastAppliedAt: number | null = null; +let betSyncLastError: string | null = null; +let betSyncConnectedAt: number | null = null; +let betSyncConsumerRunning = false; +let betSyncReplayMode: BetSyncReplayMode = "bootstrap"; +let betSyncReplayUntilSeq: number | null = null; +let betSyncLastAppliedSeq = 0; +let betSyncSourceEpoch: number | null = null; const sseClients = new Set>(); +const sessionSseClients = new Set>(); const manifestCache = new Map(); const rateBuckets = new Map(); @@ -368,6 +541,22 @@ const referralFeeShareGoldByWallet: Map = _db.referralFeeShareGoldByWallet; const treasuryFeesFromReferralsByWallet: Map = _db.treasuryFeesFromReferralsByWallet; +const persistedBetSyncCheckpoint = _db.betSyncCheckpoint; + +if (persistedBetSyncCheckpoint) { + betSyncSourceEpoch = persistedBetSyncCheckpoint.sourceEpoch; + betSyncSourceLatestSeq = persistedBetSyncCheckpoint.lastSeenSeq; + betSyncLastAppliedSeq = persistedBetSyncCheckpoint.lastAppliedSeq; + betSyncLastAppliedAt = + persistedBetSyncCheckpoint.updatedAt > 0 + ? persistedBetSyncCheckpoint.updatedAt + : null; + betSyncReplayMode = + (persistedBetSyncCheckpoint.replayMode as BetSyncReplayMode | null) ?? + (persistedBetSyncCheckpoint.lastAppliedSeq > 0 ? "live" : "bootstrap"); + betSyncLastError = persistedBetSyncCheckpoint.degradedReason; + streamLastSourceError = persistedBetSyncCheckpoint.degradedReason; +} const parsers: { solana: ParserState; @@ -379,6 +568,7 @@ const parsers: { snapshot: null, }, }; +canonicalStreamSession = buildCanonicalStreamSessionFromStreamState(streamState); function nowIso(): string { return new Date().toISOString(); @@ -1145,14 +1335,15 @@ function handlePerpsMarkets(req: Request): Response { } function handleDuelContext(req: Request): Response { + const state = toStreamStateFromCanonicalSession(canonicalStreamSession); return jsonResponse( req, { type: "STREAMING_DUEL_CONTEXT", - cycle: streamState.cycle, - leaderboard: streamState.leaderboard, - cameraTarget: streamState.cameraTarget, - updatedAt: streamState.emittedAt, + cycle: state.cycle, + leaderboard: state.leaderboard, + cameraTarget: state.cameraTarget, + updatedAt: state.emittedAt, }, 200, { @@ -1759,6 +1950,528 @@ function toStreamState(payload: unknown): StreamState | null { }; } +function deriveDefaultStreamPlaybackUrl(sourceUrl: string): string { + if (!sourceUrl) return ""; + try { + const parsed = new URL(sourceUrl); + parsed.pathname = "/live/stream.m3u8"; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); + } catch { + return ""; + } +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" + ? (value as Record) + : null; +} + +function asFiniteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function asNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function resolveConfiguredDeliveryMode(): "self_hls" | "external_hls" { + if (STREAM_DELIVERY_MODE === "self_hls") { + return "self_hls"; + } + if (STREAM_DELIVERY_MODE === "external_hls") { + return "external_hls"; + } + if ( + STREAM_PLAYBACK_LLHLS_URL || + STREAM_PLAYBACK_HLS_URL || + STREAM_INGEST_RTMPS_URL + ) { + return "external_hls"; + } + return "self_hls"; +} + +function normalizeCanonicalRendererMetrics( + value: unknown, +): CanonicalRendererMetrics | null { + const candidate = asRecord(value); + if (!candidate) return null; + const hlsManifest = asRecord(candidate.hlsManifest); + return { + captureFps: asFiniteNumber(candidate.captureFps), + encodeFps: asFiniteNumber(candidate.encodeFps), + droppedFrames: asFiniteNumber(candidate.droppedFrames), + renderTick: asFiniteNumber(candidate.renderTick), + duelStateTick: asFiniteNumber(candidate.duelStateTick), + latestFrameAt: asFiniteNumber(candidate.latestFrameAt), + latestRenderTickAt: asFiniteNumber(candidate.latestRenderTickAt), + latestDuelStateTickAt: asFiniteNumber(candidate.latestDuelStateTickAt), + latestVisualChangeAt: asFiniteNumber(candidate.latestVisualChangeAt), + visualChangeAgeMs: asFiniteNumber(candidate.visualChangeAgeMs), + hlsManifest: hlsManifest + ? { + updatedAt: asFiniteNumber(hlsManifest.updatedAt), + mediaSequence: asFiniteNumber(hlsManifest.mediaSequence), + } + : null, + }; +} + +function normalizeCanonicalDelivery( + value: unknown, +): CanonicalStreamDelivery | null { + const candidate = asRecord(value); + if (!candidate) return null; + const mode = asNonEmptyString(candidate.mode); + if (mode !== "self_hls" && mode !== "external_hls") { + return null; + } + return { + mode, + provider: asNonEmptyString(candidate.provider), + playbackUrl: asNonEmptyString(candidate.playbackUrl), + hlsUrl: asNonEmptyString(candidate.hlsUrl), + llhlsUrl: asNonEmptyString(candidate.llhlsUrl), + ingestUrl: asNonEmptyString(candidate.ingestUrl), + }; +} + +function buildCanonicalStreamDelivery( + value: unknown, +): CanonicalStreamDelivery | null { + const configuredMode = resolveConfiguredDeliveryMode(); + const candidate = normalizeCanonicalDelivery(value); + const mode = candidate?.mode ?? configuredMode; + const hlsUrl = STREAM_PLAYBACK_HLS_URL || candidate?.hlsUrl || null; + const llhlsUrl = STREAM_PLAYBACK_LLHLS_URL || candidate?.llhlsUrl || null; + const playbackUrl = + mode === "external_hls" + ? llhlsUrl || hlsUrl || candidate?.playbackUrl || STREAM_PLAYBACK_URL || null + : hlsUrl || llhlsUrl || STREAM_PLAYBACK_URL || candidate?.playbackUrl || null; + + if ( + !playbackUrl && + !STREAM_INGEST_RTMPS_URL && + !candidate?.ingestUrl && + !candidate?.provider && + !STREAM_DELIVERY_PROVIDER + ) { + return null; + } + + return { + mode, + provider: STREAM_DELIVERY_PROVIDER || candidate?.provider || null, + playbackUrl, + hlsUrl, + llhlsUrl, + ingestUrl: STREAM_INGEST_RTMPS_URL || candidate?.ingestUrl || null, + }; +} + +function normalizeCanonicalStreamHealth( + value: unknown, + fallbackUpdatedAt: number, + fallbackReady: boolean, +): CanonicalStreamHealth { + const candidate = + value && typeof value === "object" + ? (value as Record) + : null; + return { + ready: + candidate?.ready === true || + (candidate?.ready !== false && fallbackReady), + degradedReason: + typeof candidate?.degradedReason === "string" + ? candidate.degradedReason + : null, + updatedAt: + typeof candidate?.updatedAt === "number" && + Number.isFinite(candidate.updatedAt) + ? candidate.updatedAt + : fallbackUpdatedAt, + }; +} + +function canonicalStreamHealthEquals( + left: CanonicalStreamHealth | null, + right: CanonicalStreamHealth | null, +): boolean { + return ( + left?.ready === right?.ready && + left?.degradedReason === right?.degradedReason && + left?.updatedAt === right?.updatedAt + ); +} + +function extractFreshHlsManifestUpdatedAt(payload: unknown): number | null { + if (!payload || typeof payload !== "object") return null; + const candidate = payload as Record; + const hlsManifest = + candidate.hlsManifest && typeof candidate.hlsManifest === "object" + ? (candidate.hlsManifest as Record) + : null; + const updatedAt = + typeof hlsManifest?.updatedAt === "number" && + Number.isFinite(hlsManifest.updatedAt) + ? hlsManifest.updatedAt + : null; + const mediaSequence = + typeof hlsManifest?.mediaSequence === "number" && + Number.isFinite(hlsManifest.mediaSequence) + ? hlsManifest.mediaSequence + : null; + if (updatedAt == null || mediaSequence == null) return null; + return Date.now() - updatedAt <= STREAM_RENDERER_HLS_FRESHNESS_MS + ? updatedAt + : null; +} + +function toRendererHealthOverride(payload: unknown): CanonicalStreamHealth | null { + if (!payload || typeof payload !== "object") return null; + const candidate = payload as Record; + const statusCandidate = + candidate.status && typeof candidate.status === "object" + ? (candidate.status as Record) + : null; + const healthCandidate = + (candidate.rendererHealth && + typeof candidate.rendererHealth === "object" + ? candidate.rendererHealth + : null) ?? + (statusCandidate?.renderer && + typeof statusCandidate.renderer === "object" + ? statusCandidate.renderer + : null) ?? + (typeof candidate.ready === "boolean" || + typeof candidate.degradedReason === "string" || + typeof candidate.updatedAt === "number" + ? candidate + : null); + + if (!healthCandidate) return null; + const normalized = normalizeCanonicalStreamHealth( + healthCandidate, + Date.now(), + false, + ); + const freshHlsUpdatedAt = extractFreshHlsManifestUpdatedAt(candidate); + if (freshHlsUpdatedAt != null && !normalized.ready) { + return { + ready: true, + degradedReason: null, + updatedAt: freshHlsUpdatedAt, + }; + } + return normalized; +} + +function toRendererMetricsOverride( + payload: unknown, +): CanonicalRendererMetrics | null { + if (!payload || typeof payload !== "object") return null; + const candidate = payload as Record; + const metricsCandidate = + (candidate.metrics && typeof candidate.metrics === "object" + ? candidate.metrics + : null) ?? + (candidate.rendererMetrics && typeof candidate.rendererMetrics === "object" + ? candidate.rendererMetrics + : null); + + if (!metricsCandidate) return null; + const normalized = normalizeCanonicalRendererMetrics(metricsCandidate); + if (!normalized) return null; + const candidateRecord = candidate as Record; + const topLevelHlsManifest = asRecord(candidateRecord.hlsManifest); + if (topLevelHlsManifest) { + normalized.hlsManifest = { + updatedAt: asFiniteNumber(topLevelHlsManifest.updatedAt), + mediaSequence: asFiniteNumber(topLevelHlsManifest.mediaSequence), + }; + } else if (!normalized.hlsManifest) { + normalized.hlsManifest = { + updatedAt: extractFreshHlsManifestUpdatedAt(candidate), + mediaSequence: null, + }; + } + return normalized; +} + +function toDeliveryOverride(payload: unknown): CanonicalStreamDelivery | null { + if (!payload || typeof payload !== "object") return null; + const candidate = payload as Record; + return buildCanonicalStreamDelivery(candidate.delivery); +} + +function resolveCanonicalRendererHealth( + value: unknown, + fallbackUpdatedAt: number, +): CanonicalStreamHealth | null { + return ( + streamRendererHealthOverride ?? + normalizeCanonicalStreamHealth( + value, + fallbackUpdatedAt, + Boolean( + STREAM_PLAYBACK_URL || + STREAM_PLAYBACK_HLS_URL || + STREAM_PLAYBACK_LLHLS_URL, + ), + ) + ); +} + +function resolveCanonicalRendererMetrics( + value: unknown, +): CanonicalRendererMetrics | null { + return ( + streamRendererMetricsOverride ?? + normalizeCanonicalRendererMetrics(value) + ); +} + +function resolveCanonicalDelivery( + value: unknown, +): CanonicalStreamDelivery | null { + return streamDeliveryOverride ?? buildCanonicalStreamDelivery(value); +} + +function buildCanonicalStreamPlayback( + cycle: Record, + playbackCandidate: Record | null, + delivery: CanonicalStreamDelivery | null, + fallbackRenderSessionId: string, +): CanonicalStreamPlayback | null { + const candidateUrl = + typeof playbackCandidate?.url === "string" + ? playbackCandidate.url.trim() + : ""; + const playbackUrl = + delivery?.playbackUrl || STREAM_PLAYBACK_URL || candidateUrl; + if (!playbackUrl) return null; + + return { + url: playbackUrl, + kind: + delivery?.mode === "external_hls" && delivery.llhlsUrl + ? "llhls" + : delivery?.mode === "external_hls" && delivery.hlsUrl + ? "hls" + : STREAM_PLAYBACK_URL.length > 0 + ? STREAM_PLAYBACK_KIND + : typeof playbackCandidate?.kind === "string" + ? playbackCandidate.kind + : STREAM_PLAYBACK_KIND, + renderSessionId: + typeof playbackCandidate?.renderSessionId === "string" + ? playbackCandidate.renderSessionId + : ((typeof cycle.duelId === "string" && cycle.duelId) || + (typeof cycle.cycleId === "string" && cycle.cycleId) || + fallbackRenderSessionId), + presentationDelayMs: + typeof playbackCandidate?.presentationDelayMs === "number" && + Number.isFinite(playbackCandidate.presentationDelayMs) + ? Math.max(0, playbackCandidate.presentationDelayMs) + : STREAM_PRESENTATION_DELAY_MS, + }; +} + +function buildCanonicalStreamSessionFromStreamState( + next: StreamState, +): CanonicalStreamSession { + const emittedAt = + typeof next.emittedAt === "number" && Number.isFinite(next.emittedAt) + ? next.emittedAt + : Date.now(); + const cycle = next.cycle as Record; + const duelKeyHex = + typeof cycle.duelKeyHex === "string" ? cycle.duelKeyHex.trim() : null; + const delivery = resolveCanonicalDelivery(cycle.delivery); + const playback = buildCanonicalStreamPlayback( + cycle, + null, + delivery, + `stream-${next.seq}`, + ); + const rendererHealth = resolveCanonicalRendererHealth( + cycle.rendererHealth, + emittedAt, + ); + const rendererMetrics = resolveCanonicalRendererMetrics(cycle.rendererMetrics); + const authorityHealth = normalizeCanonicalStreamHealth( + { + ready: Boolean(playback?.url), + degradedReason: + playback?.url ? null : "playback_unconfigured", + updatedAt: emittedAt, + }, + emittedAt, + Boolean(playback?.url), + ); + + return { + schemaVersion: 1, + sourceEpoch: null, + seq: next.seq, + emittedAt, + duelId: typeof cycle.duelId === "string" ? cycle.duelId : null, + duelKey: duelKeyHex ? duelKeyHex.replace(/^0x/i, "").toLowerCase() : null, + phase: typeof cycle.phase === "string" ? cycle.phase : null, + phaseVersion: + typeof cycle.phaseVersion === "number" && Number.isFinite(cycle.phaseVersion) + ? cycle.phaseVersion + : null, + cycle, + leaderboard: Array.isArray(next.leaderboard) ? next.leaderboard : [], + cameraTarget: + typeof next.cameraTarget === "string" || next.cameraTarget === null + ? next.cameraTarget + : null, + playback, + rendererHealth, + rendererMetrics, + delivery, + authorityHealth, + status: { + authority: authorityHealth, + renderer: rendererHealth, + delivery, + }, + }; +} + +function toCanonicalStreamSession(payload: unknown): CanonicalStreamSession | null { + if (!payload || typeof payload !== "object") return null; + const candidate = payload as Record; + const cycle = + candidate.cycle && typeof candidate.cycle === "object" + ? (candidate.cycle as Record) + : null; + if (!cycle) return null; + + const emittedAt = + typeof candidate.emittedAt === "number" && Number.isFinite(candidate.emittedAt) + ? candidate.emittedAt + : Date.now(); + const statusCandidate = + candidate.status && typeof candidate.status === "object" + ? (candidate.status as Record) + : null; + const rendererHealth = resolveCanonicalRendererHealth( + candidate.rendererHealth ?? statusCandidate?.renderer, + emittedAt, + ); + const rendererMetrics = resolveCanonicalRendererMetrics( + candidate.rendererMetrics, + ); + const delivery = resolveCanonicalDelivery( + candidate.delivery ?? statusCandidate?.delivery, + ); + const authorityHealth = normalizeCanonicalStreamHealth( + candidate.authorityHealth ?? statusCandidate?.authority, + emittedAt, + Boolean( + delivery?.playbackUrl || + STREAM_PLAYBACK_URL || + STREAM_PLAYBACK_HLS_URL || + STREAM_PLAYBACK_LLHLS_URL, + ), + ); + const playbackCandidate = + candidate.playback && typeof candidate.playback === "object" + ? (candidate.playback as Record) + : null; + const playback = buildCanonicalStreamPlayback( + cycle, + playbackCandidate, + delivery, + "", + ); + + return { + schemaVersion: + typeof candidate.schemaVersion === "number" && + Number.isFinite(candidate.schemaVersion) + ? candidate.schemaVersion + : 1, + sourceEpoch: + typeof candidate.sourceEpoch === "number" && + Number.isFinite(candidate.sourceEpoch) + ? candidate.sourceEpoch + : null, + seq: + typeof candidate.seq === "number" && Number.isFinite(candidate.seq) + ? candidate.seq + : streamSeq + 1, + emittedAt, + duelId: + typeof candidate.duelId === "string" + ? candidate.duelId + : typeof cycle.duelId === "string" + ? cycle.duelId + : null, + duelKey: + typeof candidate.duelKey === "string" + ? candidate.duelKey.replace(/^0x/i, "").toLowerCase() + : typeof cycle.duelKeyHex === "string" + ? cycle.duelKeyHex.replace(/^0x/i, "").toLowerCase() + : null, + phase: + typeof candidate.phase === "string" + ? candidate.phase + : typeof cycle.phase === "string" + ? cycle.phase + : null, + phaseVersion: + typeof candidate.phaseVersion === "number" && + Number.isFinite(candidate.phaseVersion) + ? candidate.phaseVersion + : typeof cycle.phaseVersion === "number" && + Number.isFinite(cycle.phaseVersion) + ? cycle.phaseVersion + : null, + cycle, + leaderboard: Array.isArray(candidate.leaderboard) ? candidate.leaderboard : [], + cameraTarget: + typeof candidate.cameraTarget === "string" || candidate.cameraTarget === null + ? (candidate.cameraTarget as string | null) + : null, + playback, + rendererHealth, + rendererMetrics, + delivery, + authorityHealth, + status: { + authority: authorityHealth, + renderer: rendererHealth, + delivery, + }, + }; +} + +function toStreamStateFromCanonicalSession( + session: CanonicalStreamSession, +): StreamState { + return { + type: "STREAMING_STATE_UPDATE", + cycle: { + ...session.cycle, + rendererHealth: session.rendererHealth, + }, + leaderboard: session.leaderboard, + cameraTarget: session.cameraTarget, + seq: session.seq, + emittedAt: session.emittedAt, + }; +} + function sendSse( controller: ReadableStreamDefaultController, event: string, @@ -1780,23 +2493,382 @@ function broadcastStreamState(nextState: StreamState, event = "state"): void { } } -function publishStreamState(next: StreamState, sourceLabel: string): void { +function broadcastCanonicalStreamSession( + nextSession: CanonicalStreamSession, + event = "session", +): void { + for (const controller of sessionSseClients) { + try { + sendSse(controller, event, nextSession.seq, nextSession); + } catch { + sessionSseClients.delete(controller); + } + } +} + +function publishCanonicalStreamSession( + next: CanonicalStreamSession, + sourceLabel: string, +): void { + const emittedAt = + typeof next.emittedAt === "number" && Number.isFinite(next.emittedAt) + ? next.emittedAt + : Date.now(); + const resolvedRendererHealth = resolveCanonicalRendererHealth( + next.rendererHealth ?? next.status?.renderer, + emittedAt, + ); + const resolvedRendererMetrics = resolveCanonicalRendererMetrics( + next.rendererMetrics, + ); + const resolvedDelivery = resolveCanonicalDelivery( + next.delivery ?? next.status?.delivery, + ); + const resolvedPlayback = buildCanonicalStreamPlayback( + next.cycle, + next.playback as Record | null, + resolvedDelivery, + next.duelId || `stream-${streamSeq + 1}`, + ); streamSeq = Math.max(streamSeq + 1, next.seq || streamSeq + 1); - streamState = { + canonicalStreamSession = { ...next, - type: "STREAMING_STATE_UPDATE", + cycle: { + ...next.cycle, + rendererHealth: resolvedRendererHealth, + }, seq: streamSeq, emittedAt: Date.now(), + playback: resolvedPlayback, + rendererHealth: resolvedRendererHealth, + rendererMetrics: resolvedRendererMetrics, + delivery: resolvedDelivery, + authorityHealth: next.authorityHealth, + status: { + authority: next.authorityHealth, + renderer: resolvedRendererHealth, + delivery: resolvedDelivery, + }, }; + streamState = toStreamStateFromCanonicalSession(canonicalStreamSession); streamLastUpdatedAt = Date.now(); streamLastSourceError = null; persistStreamStateSnapshot(streamState); broadcastStreamState(streamState, "state"); + broadcastCanonicalStreamSession(canonicalStreamSession, "session"); console.log( `[${nowIso()}] [stream] updated from ${sourceLabel} cycle=${streamState.cycle?.cycleId ?? "unknown"} phase=${streamState.cycle?.phase ?? "unknown"}`, ); } +function publishStreamState(next: StreamState, sourceLabel: string): void { + publishCanonicalStreamSession( + buildCanonicalStreamSessionFromStreamState({ + ...next, + type: "STREAMING_STATE_UPDATE", + seq: + typeof next.seq === "number" && Number.isFinite(next.seq) + ? next.seq + : streamSeq + 1, + emittedAt: + typeof next.emittedAt === "number" && Number.isFinite(next.emittedAt) + ? next.emittedAt + : Date.now(), + }), + sourceLabel, + ); +} + +function nextBetSyncCheckpointState( + next: Partial = {}, +): DbBetSyncCheckpoint { + return { + sourceEpoch: betSyncSourceEpoch ?? 0, + lastSeenSeq: Math.max(betSyncSourceLatestSeq, betSyncLastAppliedSeq), + lastAppliedSeq: betSyncLastAppliedSeq, + replayMode: betSyncReplayMode, + degradedReason: betSyncLastError, + updatedAt: Date.now(), + ...next, + }; +} + +function persistBetSyncCheckpointState( + next: Partial = {}, +): void { + saveBetSyncCheckpoint(nextBetSyncCheckpointState(next)); +} + +function buildBetSyncHeaders(resumeSeq?: number): Record { + const headers: Record = { + connection: "close", + }; + if (BET_SYNC_SOURCE_BEARER_TOKEN) { + headers.authorization = `Bearer ${BET_SYNC_SOURCE_BEARER_TOKEN}`; + } + if (typeof resumeSeq === "number" && Number.isFinite(resumeSeq) && resumeSeq > 0) { + headers["last-event-id"] = String(resumeSeq); + } + return headers; +} + +function buildCanonicalStreamSessionFromBetSyncEvent( + event: BetSyncEvent, +): CanonicalStreamSession { + const nextState = toStreamStateFromBetSyncEvent(event) as unknown as StreamState; + const base = buildCanonicalStreamSessionFromStreamState(nextState); + return { + ...base, + schemaVersion: event.schemaVersion, + sourceEpoch: event.sourceEpoch, + seq: event.seq, + emittedAt: event.emittedAt, + duelId: event.duelId, + duelKey: event.duelKey, + phase: event.phase, + phaseVersion: event.phaseVersion, + rendererMetrics: resolveCanonicalRendererMetrics(event.rendererMetrics), + delivery: resolveCanonicalDelivery(event.delivery), + }; +} + +function applyBetSyncEvent( + event: BetSyncEvent, + sourceLabel: string, +): void { + betSyncSourceLatestSeq = Math.max(betSyncSourceLatestSeq, event.seq); + betSyncLastEventReceivedAt = Date.now(); + const sourceEpochChanged = + betSyncSourceEpoch != null && event.sourceEpoch !== betSyncSourceEpoch; + const currentAppliedEmittedAt = + typeof canonicalStreamSession.emittedAt === "number" && + Number.isFinite(canonicalStreamSession.emittedAt) + ? canonicalStreamSession.emittedAt + : null; + + if ( + isBetSyncEventStaleAfterSourceReset({ + sourceEpochChanged, + currentStreamEmittedAt: currentAppliedEmittedAt, + eventEmittedAt: event.emittedAt, + toleranceMs: BET_SYNC_STALE_EVENT_TOLERANCE_MS, + }) + ) { + betSyncLastError = "stale_source_reset_event"; + streamLastSourceError = betSyncLastError; + persistBetSyncCheckpointState({ + sourceEpoch: event.sourceEpoch, + lastSeenSeq: betSyncSourceLatestSeq, + degradedReason: betSyncLastError, + }); + return; + } + + if (!sourceEpochChanged && event.seq <= betSyncLastAppliedSeq) { + persistBetSyncCheckpointState({ + sourceEpoch: event.sourceEpoch, + lastSeenSeq: betSyncSourceLatestSeq, + degradedReason: null, + }); + return; + } + + publishCanonicalStreamSession( + buildCanonicalStreamSessionFromBetSyncEvent(event), + sourceLabel, + ); + betSyncSourceEpoch = event.sourceEpoch; + betSyncLastAppliedSeq = event.seq; + betSyncLastAppliedAt = Date.now(); + if (sourceEpochChanged) { + betSyncReplayUntilSeq = null; + } + betSyncLastError = null; + streamLastSourceError = null; + persistBetSyncCheckpointState({ + sourceEpoch: event.sourceEpoch, + lastSeenSeq: betSyncSourceLatestSeq, + lastAppliedSeq: betSyncLastAppliedSeq, + replayMode: betSyncReplayMode, + degradedReason: null, + }); + resetStreamSourceFailures(); +} + +async function bootstrapBetSyncState(): Promise { + if (!BET_SYNC_SOURCE_STATE_URL) return; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), BET_SYNC_CONNECT_TIMEOUT_MS); + try { + const response = await fetch(BET_SYNC_SOURCE_STATE_URL, { + cache: "no-store", + headers: buildBetSyncHeaders(), + signal: controller.signal, + }); + streamLastSourcePollAt = Date.now(); + if (!response.ok) { + throw new Error(`bootstrap failed (${response.status})`); + } + + const state = parseBetSyncBootstrapState(await response.json()); + if (!state) { + throw new Error("bootstrap payload was invalid"); + } + + const bootstrapCursor = resolveBetSyncBootstrapCursor({ + previousSourceEpoch: betSyncSourceEpoch, + nextSourceEpoch: state.sourceEpoch, + previousLastAppliedSeq: betSyncLastAppliedSeq, + latestSeq: state.latestSeq, + }); + + if (bootstrapCursor.sourceEpochChanged) { + betSyncLastAppliedAt = null; + } + + betSyncSourceEpoch = state.sourceEpoch; + betSyncSourceLatestSeq = state.latestSeq; + betSyncOldestReplaySeq = state.oldestReplaySeq; + betSyncLastAppliedSeq = bootstrapCursor.rebasedLastAppliedSeq; + betSyncReplayUntilSeq = bootstrapCursor.replayUntilSeq; + betSyncReplayMode = bootstrapCursor.replayMode; + persistBetSyncCheckpointState({ + sourceEpoch: state.sourceEpoch, + lastSeenSeq: state.latestSeq, + lastAppliedSeq: betSyncLastAppliedSeq, + replayMode: betSyncReplayMode, + degradedReason: null, + }); + + if (state.latestEvent && betSyncReplayUntilSeq == null) { + applyBetSyncEvent(state.latestEvent, "bet-sync-bootstrap"); + } + } finally { + clearTimeout(timeoutId); + } +} + +async function consumeBetSyncEventsOnce(): Promise { + if (!BET_SYNC_SOURCE_EVENTS_URL) return; + + const url = new URL(BET_SYNC_SOURCE_EVENTS_URL); + const resumeSeq = selectBetSyncResumeSeq({ + lastAppliedSeq: betSyncLastAppliedSeq, + }); + if (resumeSeq > 0) { + url.searchParams.set("since", String(resumeSeq)); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), BET_SYNC_CONNECT_TIMEOUT_MS); + const response = await fetch(url, { + cache: "no-store", + headers: { + ...buildBetSyncHeaders(resumeSeq), + accept: "text/event-stream", + }, + signal: controller.signal, + }); + clearTimeout(timeoutId); + + if (!response.ok || !response.body) { + throw new Error(`bet-sync stream failed (${response.status})`); + } + + betSyncConnectedAt = Date.now(); + let buffer = ""; + for await (const chunk of response.body as unknown as AsyncIterable) { + buffer += decoder.decode(chunk, { stream: true }); + let boundary = buffer.indexOf("\n\n"); + while (boundary >= 0) { + const frame = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + boundary = buffer.indexOf("\n\n"); + + if (!frame.trim() || frame.startsWith(":")) { + continue; + } + + const lines = frame.split(/\r?\n/); + let eventName = "message"; + let data = ""; + for (const line of lines) { + if (line.startsWith("event:")) { + eventName = line.slice(6).trim(); + continue; + } + if (line.startsWith("data:")) { + data += `${line.slice(5).trim()}\n`; + } + } + + if (eventName === "heartbeat" || !data.trim()) { + continue; + } + + let decodedPayload: unknown; + try { + decodedPayload = JSON.parse(data); + } catch { + continue; + } + + const parsed = parseBetSyncEvent(decodedPayload); + if (!parsed) { + continue; + } + + const replayDecision = resolveBetSyncReplayMode({ + eventName, + eventSeq: parsed.seq, + replayUntilSeq: betSyncReplayUntilSeq, + sourceEpochChanged: + betSyncSourceEpoch != null && parsed.sourceEpoch !== betSyncSourceEpoch, + }); + betSyncReplayMode = replayDecision.replayMode; + betSyncReplayUntilSeq = replayDecision.replayUntilSeq; + if (eventName === "reset") { + betSyncOldestReplaySeq = parsed.seq; + } + + applyBetSyncEvent(parsed, `bet-sync-${eventName}`); + } + } +} + +function startBetSyncConsumer(): void { + if (!BET_SYNC_SOURCE_EVENTS_URL || betSyncConsumerRunning) return; + betSyncConsumerRunning = true; + void (async () => { + let reconnectDelayMs = BET_SYNC_RECONNECT_MIN_MS; + while (true) { + try { + await bootstrapBetSyncState(); + await consumeBetSyncEventsOnce(); + reconnectDelayMs = BET_SYNC_RECONNECT_MIN_MS; + betSyncLastError = null; + } catch (error) { + betSyncLastError = + error instanceof Error ? error.message : "bet-sync consumer failed"; + streamLastSourceError = betSyncLastError; + persistBetSyncCheckpointState({ + replayMode: betSyncReplayMode, + degradedReason: betSyncLastError, + }); + registerStreamSourceFailure(streamLastSourceError); + console.warn( + `[${nowIso()}] [bet-sync] consumer error: ${betSyncLastError}; reconnecting in ${reconnectDelayMs}ms`, + ); + await new Promise((resolve) => setTimeout(resolve, reconnectDelayMs)); + reconnectDelayMs = Math.min( + BET_SYNC_RECONNECT_MAX_MS, + reconnectDelayMs * 2, + ); + } + } + })(); +} + function nextStreamSourceBackoffMs(): number { const step = Math.min(streamSourceConsecutiveFailures, 5); return Math.min( @@ -1826,7 +2898,7 @@ function resetStreamSourceFailures(): void { } async function pollStreamStateSource(): Promise { - if (!STREAM_STATE_SOURCE_URL) return; + if (!ACTIVE_STREAM_STATE_SOURCE_URL) return; if (streamSourcePollInFlight) return; if (Date.now() < streamSourceBackoffUntil) return; @@ -1839,12 +2911,12 @@ async function pollStreamStateSource(): Promise { try { const headers: Record = {}; - if (STREAM_STATE_SOURCE_BEARER_TOKEN) { - headers.authorization = `Bearer ${STREAM_STATE_SOURCE_BEARER_TOKEN}`; + if (ACTIVE_STREAM_SOURCE_BEARER_TOKEN) { + headers.authorization = `Bearer ${ACTIVE_STREAM_SOURCE_BEARER_TOKEN}`; } headers.connection = "close"; - const response = await fetch(STREAM_STATE_SOURCE_URL, { + const response = await fetch(ACTIVE_STREAM_STATE_SOURCE_URL, { cache: "no-store", headers, signal: controller.signal, @@ -1862,6 +2934,28 @@ async function pollStreamStateSource(): Promise { } const payload = await response.json(); + const nextSession = + toCanonicalStreamSession(payload) || + toCanonicalStreamSession((payload as Record)?.data); + + if (nextSession) { + const changed = + canonicalStreamSession.seq !== nextSession.seq || + canonicalStreamSession.duelId !== nextSession.duelId || + canonicalStreamSession.phase !== nextSession.phase || + canonicalStreamSession.playback?.url !== nextSession.playback?.url || + canonicalStreamSession.rendererHealth?.ready !== + nextSession.rendererHealth?.ready || + canonicalStreamSession.rendererHealth?.degradedReason !== + nextSession.rendererHealth?.degradedReason; + if (changed) { + publishCanonicalStreamSession(nextSession, "poll"); + } + streamLastSourceError = null; + resetStreamSourceFailures(); + return; + } + const nextState = toStreamState(payload) || toStreamState((payload as Record)?.data); @@ -1891,6 +2985,69 @@ async function pollStreamStateSource(): Promise { } } +async function pollRendererHealthSource(): Promise { + if (!STREAM_RENDERER_HEALTH_URL || streamRendererHealthPollInFlight) return; + + streamRendererHealthPollInFlight = true; + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + STREAM_RENDERER_HEALTH_TIMEOUT_MS, + ); + + try { + const headers: Record = { connection: "close" }; + if (STREAM_RENDERER_HEALTH_BEARER_TOKEN) { + headers.authorization = `Bearer ${STREAM_RENDERER_HEALTH_BEARER_TOKEN}`; + } + const response = await fetch(STREAM_RENDERER_HEALTH_URL, { + cache: "no-store", + headers, + signal: controller.signal, + }); + + if (!response.ok) { + try { + await response.body?.cancel(); + } catch { + // Ignore cancellation issues for already-closed streams. + } + return; + } + + const payload = await response.json(); + const nextHealth = + toRendererHealthOverride(payload) || + toRendererHealthOverride((payload as Record)?.data); + const nextMetrics = + toRendererMetricsOverride(payload) || + toRendererMetricsOverride((payload as Record)?.data); + const nextDelivery = + toDeliveryOverride(payload) || + toDeliveryOverride((payload as Record)?.data); + if (!nextHealth && !nextMetrics && !nextDelivery) return; + + if ( + canonicalStreamHealthEquals(streamRendererHealthOverride, nextHealth) && + JSON.stringify(streamRendererMetricsOverride) === + JSON.stringify(nextMetrics) && + JSON.stringify(streamDeliveryOverride) === JSON.stringify(nextDelivery) + ) { + return; + } + + streamRendererHealthOverride = nextHealth; + streamRendererMetricsOverride = nextMetrics; + streamDeliveryOverride = nextDelivery; + publishCanonicalStreamSession(canonicalStreamSession, "renderer-health"); + } catch { + // Defer to the canonical authority when the override source is unavailable. + } finally { + clearTimeout(timeoutId); + streamRendererHealthPollInFlight = false; + } +} + function connectedSseCount(): number { return sseClients.size; } @@ -2974,11 +4131,25 @@ const server = Bun.serve({ cycleId: streamState.cycle?.cycleId ?? null, phase: streamState.cycle?.phase ?? null, lastUpdatedAt: streamLastUpdatedAt, - sourceUrl: STREAM_STATE_SOURCE_URL - ? sanitizeUrlForStatus(STREAM_STATE_SOURCE_URL) + sourceUrl: BET_SYNC_SOURCE_EVENTS_URL + ? sanitizeUrlForStatus(BET_SYNC_SOURCE_EVENTS_URL) + : ACTIVE_STREAM_STATE_SOURCE_URL + ? sanitizeUrlForStatus(ACTIVE_STREAM_STATE_SOURCE_URL) : null, lastSourcePollAt: streamLastSourcePollAt, - lastSourceError: streamLastSourceError, + lastSourceError: betSyncLastError ?? streamLastSourceError, + betSync: { + enabled: Boolean(BET_SYNC_SOURCE_EVENTS_URL), + replayMode: betSyncReplayMode, + sourceEpoch: betSyncSourceEpoch, + sourceLatestSeq: betSyncSourceLatestSeq, + oldestReplaySeq: betSyncOldestReplaySeq, + lastAppliedSeq: betSyncLastAppliedSeq, + connectedAt: betSyncConnectedAt, + lastEventReceivedAt: betSyncLastEventReceivedAt, + lastAppliedAt: betSyncLastAppliedAt, + lastError: betSyncLastError, + }, sseClients: connectedSseCount(), }, parsers, @@ -3025,7 +4196,18 @@ const server = Bun.serve({ } if (req.method === "GET" && url.pathname === "/api/streaming/state") { - return jsonResponse(req, streamState, 200, { + return jsonResponse( + req, + toStreamStateFromCanonicalSession(canonicalStreamSession), + 200, + { + "cache-control": "no-store", + }, + ); + } + + if (req.method === "GET" && url.pathname === "/api/streaming/session") { + return jsonResponse(req, canonicalStreamSession, 200, { "cache-control": "no-store", }); } @@ -3095,6 +4277,37 @@ const server = Bun.serve({ return new Response(stream, { status: 200, headers }); } + if ( + req.method === "GET" && + url.pathname === "/api/streaming/session/events" + ) { + const stream = new ReadableStream({ + start(controller) { + sessionSseClients.add(controller); + sendSse( + controller, + "reset", + canonicalStreamSession.seq, + canonicalStreamSession, + ); + controller.enqueue(encoder.encode(": connected\n\n")); + }, + cancel(reason) { + void reason; + }, + }); + + const headers = new Headers({ + "content-type": "text/event-stream; charset=utf-8", + "cache-control": + "no-store, no-cache, must-revalidate, proxy-revalidate", + connection: "keep-alive", + ...securityHeaders(), + }); + applyCors(req, headers); + return new Response(stream, { status: 200, headers }); + } + if ( req.method === "POST" && url.pathname === "/api/streaming/state/publish" @@ -3277,9 +4490,14 @@ setInterval(() => { } }, 20_000); -if (STREAM_STATE_SOURCE_URL) { +if (BET_SYNC_SOURCE_EVENTS_URL) { + console.log( + `[${nowIso()}] [bet-sync] consuming ${BET_SYNC_SOURCE_EVENTS_URL}`, + ); + startBetSyncConsumer(); +} else if (ACTIVE_STREAM_STATE_SOURCE_URL) { console.log( - `[${nowIso()}] [stream] polling source ${STREAM_STATE_SOURCE_URL}`, + `[${nowIso()}] [stream] polling source ${ACTIVE_STREAM_STATE_SOURCE_URL}`, ); setInterval(() => { void pollStreamStateSource(); @@ -3287,6 +4505,16 @@ if (STREAM_STATE_SOURCE_URL) { void pollStreamStateSource(); } +if (STREAM_RENDERER_HEALTH_URL) { + console.log( + `[${nowIso()}] [renderer-health] polling ${STREAM_RENDERER_HEALTH_URL}`, + ); + setInterval(() => { + void pollRendererHealthSource(); + }, STREAM_RENDERER_HEALTH_POLL_MS); + void pollRendererHealthSource(); +} + setInterval(() => { void pollContractParsers(); }, CONTRACT_POLL_MS); diff --git a/packages/hyperbet-ui/package.json b/packages/hyperbet-ui/package.json index af6edf3a..f041224e 100644 --- a/packages/hyperbet-ui/package.json +++ b/packages/hyperbet-ui/package.json @@ -37,6 +37,7 @@ "./lib/programs": "./src/lib/programs.ts", "./lib/solanaCompat": "./src/lib/solanaCompat.ts", "./lib/solanaRpc": "./src/lib/solanaRpc.ts", + "./lib/streamSession": "./src/lib/streamSession.ts", "./lib/token": "./src/lib/token.ts", "./lib/useMockStreamingEngine": "./src/lib/useMockStreamingEngine.ts", "./lib/useResizePanel": "./src/lib/useResizePanel.ts", @@ -69,6 +70,7 @@ "./spectator/Leaderboard": "./src/spectator/Leaderboard.tsx", "./spectator/SpectatorPanel": "./src/spectator/SpectatorPanel.tsx", "./spectator/types": "./src/spectator/types.ts", + "./spectator/useCanonicalStreamSession": "./src/spectator/useCanonicalStreamSession.ts", "./spectator/useDuelContext": "./src/spectator/useDuelContext.ts", "./spectator/useStreamingState": "./src/spectator/useStreamingState.ts" }, diff --git a/packages/hyperbet-ui/src/components/StreamPlayer.tsx b/packages/hyperbet-ui/src/components/StreamPlayer.tsx index 0d202acc..6897dbbe 100644 --- a/packages/hyperbet-ui/src/components/StreamPlayer.tsx +++ b/packages/hyperbet-ui/src/components/StreamPlayer.tsx @@ -1,5 +1,12 @@ -import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import Hls from "hls.js"; +import { describeCanonicalRendererDegradedReason } from "../lib/streamSession"; interface StreamPlayerProps { streamUrl: string; @@ -12,6 +19,51 @@ interface StreamPlayerProps { onStreamReady?: () => void; } +type PlayerTelemetry = { + liveEdgeLatencyMs: number | null; + stallCount: number; + rebuildCount: number; + lastBufferedFragmentAt: number | null; + playbackUrl: string | null; + deliveryMode: string | null; +}; + +type EmbedStatusPayload = { + type?: string; + ready?: boolean; + status?: string | null; + liveEdgeLatencyMs?: number | null; + stallCount?: number | null; + rebuildCount?: number | null; + lastBufferedFragmentAt?: number | null; + playbackUrl?: string | null; + deliveryMode?: string | null; + rendererHealth?: { + ready?: boolean; + degradedReason?: string | null; + } | null; +}; + +export const LIVE_EDGE_HLS_CONFIG = { + enableWorker: true, + lowLatencyMode: true, + liveSyncDurationCount: 2, + liveMaxLatencyDurationCount: 4, + liveBackBufferLength: 10, + maxBufferLength: 6, + maxMaxBufferLength: 12, + maxLiveSyncPlaybackRate: 1.5, + startFragPrefetch: true, + manifestLoadingMaxRetry: 6, + manifestLoadingRetryDelay: 800, + levelLoadingMaxRetry: 6, + levelLoadingRetryDelay: 800, + fragLoadingMaxRetry: 6, + fragLoadingRetryDelay: 800, +} as const; + +const PLAYER_DRIFT_THRESHOLD_MS = 8_000; + export const StreamPlayer: React.FC = ({ streamUrl, poster, @@ -27,16 +79,56 @@ export const StreamPlayer: React.FC = ({ () => resolveEmbedUrl(streamUrl, autoPlay, muted), [autoPlay, muted, streamUrl], ); + const embedKind = useMemo(() => classifyEmbedKind(embedUrl), [embedUrl]); const unavailableNotifiedRef = useRef(false); + const readyNotifiedRef = useRef(false); + const [embedFailure, setEmbedFailure] = useState(null); + const [diagnosticMessage, setDiagnosticMessage] = useState(null); + const [telemetry, setTelemetry] = useState(() => ({ + liveEdgeLatencyMs: null, + stallCount: 0, + rebuildCount: 0, + lastBufferedFragmentAt: null, + playbackUrl: streamUrl.trim() || null, + deliveryMode: inferDeliveryMode(streamUrl), + })); - const markUnavailable = useCallback(() => { - if (unavailableNotifiedRef.current) return; - unavailableNotifiedRef.current = true; - onStreamUnavailable?.(); - }, [onStreamUnavailable]); + const markUnavailable = useCallback( + (reason = "Live stream unavailable.") => { + setDiagnosticMessage(reason); + setEmbedFailure((current) => current ?? reason); + if (unavailableNotifiedRef.current) return; + unavailableNotifiedRef.current = true; + onStreamUnavailable?.(); + }, + [onStreamUnavailable], + ); + + const markReady = useCallback(() => { + setEmbedFailure(null); + setDiagnosticMessage(null); + if (readyNotifiedRef.current) return; + readyNotifiedRef.current = true; + onStreamReady?.(); + }, [onStreamReady]); + + const markDegraded = useCallback((reason: string | null) => { + setDiagnosticMessage(reason); + }, []); useEffect(() => { unavailableNotifiedRef.current = false; + readyNotifiedRef.current = false; + setEmbedFailure(null); + setDiagnosticMessage(null); + setTelemetry({ + liveEdgeLatencyMs: null, + stallCount: 0, + rebuildCount: 0, + lastBufferedFragmentAt: null, + playbackUrl: streamUrl.trim() || null, + deliveryMode: inferDeliveryMode(streamUrl), + }); }, [streamUrl]); useEffect(() => { @@ -44,6 +136,118 @@ export const StreamPlayer: React.FC = ({ markUnavailable(); }, [embedUrl, markUnavailable]); + useEffect(() => { + if (embedKind !== "hyperscape-public") return; + markUnavailable( + "Invalid stream configuration. Embedded Hyperscapes streams must use a tokenized /stream URL.", + ); + }, [embedKind, markUnavailable]); + + useEffect(() => { + if ( + !embedUrl || + (embedKind !== "hyperscape" && embedKind !== "hls-player") || + typeof window === "undefined" + ) { + return; + } + + const embedOrigin = getEmbedOrigin(embedUrl); + if (!embedOrigin) { + return; + } + + const expectedMessageType = + embedKind === "hyperscape" + ? "HYPERSCAPE_STREAM_STATUS" + : "HLS_PLAYER_STATUS"; + let seenStatusMessage = false; + const bootstrapTimeout = window.setTimeout(() => { + if (!seenStatusMessage) { + markUnavailable( + embedKind === "hyperscape" + ? "Failed to initialize the embedded Hyperscapes stream." + : "Failed to initialize the embedded HLS stream.", + ); + } + }, 10_000); + + const handleMessage = (event: MessageEvent) => { + if (event.origin !== embedOrigin) return; + if (!event.data || typeof event.data !== "object") return; + + const payload = event.data as EmbedStatusPayload; + if (payload.type !== expectedMessageType) { + return; + } + + seenStatusMessage = true; + window.clearTimeout(bootstrapTimeout); + setTelemetry((current) => ({ + liveEdgeLatencyMs: + typeof payload.liveEdgeLatencyMs === "number" && + Number.isFinite(payload.liveEdgeLatencyMs) + ? payload.liveEdgeLatencyMs + : current.liveEdgeLatencyMs, + stallCount: + typeof payload.stallCount === "number" && + Number.isFinite(payload.stallCount) + ? payload.stallCount + : current.stallCount, + rebuildCount: + typeof payload.rebuildCount === "number" && + Number.isFinite(payload.rebuildCount) + ? payload.rebuildCount + : current.rebuildCount, + lastBufferedFragmentAt: + typeof payload.lastBufferedFragmentAt === "number" && + Number.isFinite(payload.lastBufferedFragmentAt) + ? payload.lastBufferedFragmentAt + : current.lastBufferedFragmentAt, + playbackUrl: + typeof payload.playbackUrl === "string" && payload.playbackUrl.trim().length > 0 + ? payload.playbackUrl.trim() + : current.playbackUrl, + deliveryMode: + typeof payload.deliveryMode === "string" && payload.deliveryMode.trim().length > 0 + ? payload.deliveryMode.trim() + : current.deliveryMode, + })); + + if (payload.ready === true) { + markReady(); + return; + } + + const degradedStatus = + typeof payload.status === "string" && payload.status.trim().length > 0 + ? payload.status.trim() + : typeof payload.rendererHealth?.degradedReason === "string" && + payload.rendererHealth.degradedReason.trim().length > 0 + ? payload.rendererHealth.degradedReason.trim() + : null; + + if (degradedStatus && degradedStatus.startsWith("error:")) { + markUnavailable( + embedKind === "hyperscape" + ? describeHyperscapeEmbedError(degradedStatus) + : describeHlsEmbedError(degradedStatus), + ); + return; + } + + if (!isTransientPlayerStatus(degradedStatus)) { + markDegraded(describePlayerStatus(degradedStatus, embedKind)); + } + }; + + window.addEventListener("message", handleMessage); + return () => { + window.clearTimeout(bootstrapTimeout); + window.removeEventListener("message", handleMessage); + }; + }, [embedKind, embedUrl, markDegraded, markReady, markUnavailable]); + useEffect(() => { // External embeddable URLs render through iframe mode below. if (embedUrl) return; @@ -53,250 +257,323 @@ export const StreamPlayer: React.FC = ({ let hls: Hls | null = null; let retryTimeout: ReturnType | null = null; - let healthWatchdog: ReturnType | null = null; - let lastPlaybackTime = 0; - let lastPlaylistUpdateAt = Date.now(); - let stallCount = 0; + let latencyInterval: ReturnType | null = null; + let recoveryCooldownUntil = 0; + let fatalErrorCount = 0; let disposed = false; + const sourceUrl = streamUrl.trim(); - const clearTimers = () => { - if (retryTimeout) { - clearTimeout(retryTimeout); - retryTimeout = null; - } - if (healthWatchdog) { - clearInterval(healthWatchdog); - healthWatchdog = null; - } + const updateTelemetry = ( + next: + | Partial + | ((current: PlayerTelemetry) => PlayerTelemetry), + ) => { + setTelemetry((current) => + typeof next === "function" + ? next(current) + : { + ...current, + ...next, + }, + ); }; - const sourceUrl = () => - `${streamUrl}${streamUrl.includes("?") ? "&" : "?"}t=${Date.now()}`; - - const probeManifest = async () => { - try { - const response = await fetch(sourceUrl(), { cache: "no-store" }); - if (!response.ok) return false; - const text = await response.text(); - // A valid live playlist should include media segments. - return /#EXTINF/i.test(text) && /\.(ts|m4s|mp4)\b/i.test(text); - } catch { - return false; - } + const clearRetry = () => { + if (!retryTimeout) return; + clearTimeout(retryTimeout); + retryTimeout = null; }; - const nudgeToLiveEdge = () => { - if (!video) return; + const clearLatencyInterval = () => { + if (!latencyInterval) return; + clearInterval(latencyInterval); + latencyInterval = null; + }; - const syncPosition = hls?.liveSyncPosition; - if (typeof syncPosition === "number" && Number.isFinite(syncPosition)) { - if (syncPosition - video.currentTime > 1) { - video.currentTime = Math.max(0, syncPosition - 0.5); - } - } else if (video.buffered.length > 0) { - const liveEdge = video.buffered.end(video.buffered.length - 1); - if (liveEdge - video.currentTime > 1) { - video.currentTime = Math.max(0, liveEdge - 0.5); - } + const syncLatencyTelemetry = () => { + const latencyMs = readLiveEdgeLatencyMs(hls, video); + updateTelemetry({ + liveEdgeLatencyMs: latencyMs, + playbackUrl: sourceUrl, + deliveryMode: inferDeliveryMode(sourceUrl), + }); + if (latencyMs != null && latencyMs > PLAYER_DRIFT_THRESHOLD_MS) { + markDegraded( + describeCanonicalRendererDegradedReason("player_drifted"), + ); } - - void video.play().catch(() => {}); }; - const scheduleRebuild = (reason: string, delayMs = 1500) => { - console.warn(`[StreamPlayer] Rebuilding stream: ${reason}`); - if (retryTimeout) clearTimeout(retryTimeout); - retryTimeout = setTimeout(() => { - void initPlayer(); - }, delayMs); + const startLatencyPolling = () => { + clearLatencyInterval(); + latencyInterval = setInterval(syncLatencyTelemetry, 1000); }; - const startHealthWatchdog = () => { - if (healthWatchdog) clearInterval(healthWatchdog); - - lastPlaybackTime = 0; - stallCount = 0; - - // Recovery loop for tiny stalls and stale playlist updates. - healthWatchdog = setInterval(() => { - if (!video) return; + const recoverPlayback = ( + reason: string, + { + reloadSource = false, + recoverMedia = false, + delayMs = 0, + }: { + reloadSource?: boolean; + recoverMedia?: boolean; + delayMs?: number; + } = {}, + ) => { + const run = () => { + if (disposed) return; const now = Date.now(); - const playbackDelta = Math.abs(video.currentTime - lastPlaybackTime); - const stalled = - video.currentTime > 0 && - playbackDelta < 0.01 && - !video.paused && - !video.ended; - - if (stalled) { - stallCount += 1; - console.warn( - `[StreamPlayer] Playback stalled (count: ${stallCount})`, - ); - - if (stallCount >= 3) { - scheduleRebuild("playback stalled repeatedly"); - return; - } - - if (stallCount === 1) { - nudgeToLiveEdge(); - } else { - hls?.recoverMediaError(); - nudgeToLiveEdge(); - } - } else { - stallCount = 0; + if (now < recoveryCooldownUntil) { + return; } + recoveryCooldownUntil = now + 2500; - if (hls && now - lastPlaylistUpdateAt > 8000) { - console.warn( - "[StreamPlayer] Playlist stalled; forcing manifest/fragment reload", - ); - hls.startLoad(); - nudgeToLiveEdge(); - lastPlaylistUpdateAt = now; + console.warn(`[StreamPlayer] Recovering playback: ${reason}`); + if (recoverMedia) { + hls?.recoverMediaError(); + } + if (reloadSource) { + hls?.startLoad(-1); } + syncLatencyTelemetry(); + void video.play().catch(() => {}); + }; + + clearRetry(); + if (delayMs > 0) { + retryTimeout = setTimeout(run, delayMs); + return; + } - lastPlaybackTime = video.currentTime; - }, 2000); + run(); + }; + + const rebuildPlayer = (reason: string, delayMs = 1500) => { + console.warn(`[StreamPlayer] Rebuilding stream: ${reason}`); + updateTelemetry((current) => ({ + ...current, + rebuildCount: current.rebuildCount + 1, + })); + markDegraded("Rebuilding live stream..."); + clearRetry(); + retryTimeout = setTimeout(() => { + if (disposed) return; + void initPlayer(); + }, delayMs); }; const initPlayer = async () => { if (disposed) return; - clearTimers(); + + clearRetry(); + fatalErrorCount = 0; + recoveryCooldownUntil = 0; + if (hls) { hls.destroy(); hls = null; } - lastPlaylistUpdateAt = Date.now(); + clearLatencyInterval(); + updateTelemetry((current) => ({ + ...current, + liveEdgeLatencyMs: null, + lastBufferedFragmentAt: null, + playbackUrl: sourceUrl, + deliveryMode: inferDeliveryMode(sourceUrl), + })); - const manifestReady = await probeManifest(); - if (!manifestReady) { - scheduleRebuild("manifest not ready", 1000); - return; + video.preload = "auto"; + if (autoPlay) { + video.autoplay = true; } + video.muted = muted; - // Check if browser supports HLS natively (Safari) if (video.canPlayType("application/vnd.apple.mpegurl")) { - video.src = sourceUrl(); + video.src = sourceUrl; + startLatencyPolling(); + syncLatencyTelemetry(); void video.play().catch(() => {}); - startHealthWatchdog(); - } else if (Hls.isSupported()) { - hls = new Hls({ - enableWorker: true, - // FFmpeg emits standard live HLS, not LL-HLS parts. - lowLatencyMode: false, - // Keep a wider live window to absorb network jitter. - liveSyncDurationCount: 4, - liveMaxLatencyDurationCount: 12, - liveBackBufferLength: 30, - maxBufferLength: 30, - maxMaxBufferLength: 60, - // Aggressive retries when manifests/fragments fail. - manifestLoadingMaxRetry: 10, - manifestLoadingRetryDelay: 800, - levelLoadingMaxRetry: 10, - levelLoadingRetryDelay: 800, - fragLoadingMaxRetry: 10, - fragLoadingRetryDelay: 800, - }); + return; + } - hls.loadSource(sourceUrl()); - hls.attachMedia(video); + if (!Hls.isSupported()) { + console.error("[StreamPlayer] HLS is not supported in this browser"); + markUnavailable("HLS is not supported in this browser."); + return; + } - hls.on(Hls.Events.MANIFEST_LOADED, () => { - lastPlaylistUpdateAt = Date.now(); - }); + hls = new Hls({ ...LIVE_EDGE_HLS_CONFIG }); - hls.on(Hls.Events.LEVEL_LOADED, () => { - lastPlaylistUpdateAt = Date.now(); - }); + hls.loadSource(sourceUrl); + hls.attachMedia(video); + startLatencyPolling(); - hls.on(Hls.Events.FRAG_LOADED, () => { - lastPlaylistUpdateAt = Date.now(); - }); + hls.on(Hls.Events.MANIFEST_PARSED, () => { + console.log("[StreamPlayer] Manifest parsed, starting playback"); + markDegraded(null); + syncLatencyTelemetry(); + void video.play().catch(() => {}); + }); - hls.on(Hls.Events.MANIFEST_PARSED, () => { - console.log("[StreamPlayer] Manifest parsed, starting playback"); - onStreamReady?.(); - void video.play().catch(() => {}); + hls.on(Hls.Events.FRAG_BUFFERED, () => { + updateTelemetry({ + lastBufferedFragmentAt: Date.now(), }); + syncLatencyTelemetry(); + markDegraded(null); + markReady(); + }); - hls.on(Hls.Events.ERROR, (_event, data) => { - console.warn( - "[StreamPlayer] HLS error:", - data.type, - data.details, - data.fatal, - ); - - if (data.fatal) { - switch (data.type) { - case Hls.ErrorTypes.NETWORK_ERROR: - console.log("[StreamPlayer] Network error, retrying load..."); - hls?.startLoad(-1); - break; - case Hls.ErrorTypes.MEDIA_ERROR: - console.log("[StreamPlayer] Media error, recovering..."); - hls?.recoverMediaError(); - nudgeToLiveEdge(); - break; - default: - scheduleRebuild("fatal HLS error", 2000); - break; - } - } else if ( + hls.on(Hls.Events.LEVEL_UPDATED, () => { + syncLatencyTelemetry(); + }); + + hls.on(Hls.Events.ERROR, (_event, data) => { + console.warn( + "[StreamPlayer] HLS error:", + data.type, + data.details, + data.fatal, + ); + + if (!data.fatal) { + if ( data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR || data.details === Hls.ErrorDetails.FRAG_LOAD_TIMEOUT || - data.details === Hls.ErrorDetails.LEVEL_LOAD_TIMEOUT || - data.details === Hls.ErrorDetails.BUFFER_APPEND_ERROR + data.details === Hls.ErrorDetails.LEVEL_LOAD_TIMEOUT ) { - console.warn( - "[StreamPlayer] Non-fatal buffering/loading issue; forcing recovery", + updateTelemetry((current) => ({ + ...current, + stallCount: current.stallCount + 1, + })); + markDegraded( + describeCanonicalRendererDegradedReason("player_drifted"), ); - hls?.startLoad(); - nudgeToLiveEdge(); + recoverPlayback("non-fatal buffering/loading issue", { + reloadSource: true, + }); + } else if (data.details === Hls.ErrorDetails.BUFFER_APPEND_ERROR) { + updateTelemetry((current) => ({ + ...current, + stallCount: current.stallCount + 1, + })); + markDegraded("Recovering live stream..."); + recoverPlayback("buffer append issue", { + recoverMedia: true, + reloadSource: true, + }); } - }); + return; + } - startHealthWatchdog(); - } else { - console.error("[StreamPlayer] HLS is not supported in this browser"); - } + fatalErrorCount += 1; + + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: + if (fatalErrorCount < 3) { + markDegraded("Reconnecting to the live edge..."); + recoverPlayback("fatal network error", { + reloadSource: true, + delayMs: 1000, + }); + } else { + rebuildPlayer("repeated fatal network error", 2000); + } + break; + case Hls.ErrorTypes.MEDIA_ERROR: + if (fatalErrorCount < 3) { + markDegraded("Recovering live playback..."); + recoverPlayback("fatal media error", { + recoverMedia: true, + reloadSource: true, + delayMs: 500, + }); + } else { + rebuildPlayer("repeated fatal media error", 2000); + } + break; + default: + if (fatalErrorCount < 2) { + rebuildPlayer("fatal HLS error", 2000); + } else { + markUnavailable("Live stream unavailable."); + } + break; + } + }); }; - const onWaiting = () => nudgeToLiveEdge(); - const onStalled = () => nudgeToLiveEdge(); - const onLoadedMetadata = () => onStreamReady?.(); - const onVideoError = () => scheduleRebuild("video element error", 1000); + const onLoadedMetadata = () => markReady(); + const onLoadedData = () => markReady(); + const onCanPlay = () => markReady(); + const onPlaying = () => { + markDegraded(null); + syncLatencyTelemetry(); + markReady(); + }; + const onWaiting = () => { + updateTelemetry((current) => ({ + ...current, + stallCount: current.stallCount + 1, + })); + markDegraded(describeCanonicalRendererDegradedReason("player_drifted")); + }; + const onStalled = () => { + updateTelemetry((current) => ({ + ...current, + stallCount: current.stallCount + 1, + })); + markDegraded(describeCanonicalRendererDegradedReason("player_drifted")); + recoverPlayback("video stalled", { + reloadSource: true, + delayMs: 500, + }); + }; + const onTimeUpdate = () => syncLatencyTelemetry(); + const onVideoError = () => rebuildPlayer("video element error", 1000); + video.addEventListener("loadedmetadata", onLoadedMetadata); + video.addEventListener("loadeddata", onLoadedData); + video.addEventListener("canplay", onCanPlay); + video.addEventListener("playing", onPlaying); video.addEventListener("waiting", onWaiting); video.addEventListener("stalled", onStalled); - video.addEventListener("loadedmetadata", onLoadedMetadata); + video.addEventListener("timeupdate", onTimeUpdate); video.addEventListener("error", onVideoError); - if (autoPlay) { - video.autoplay = true; - } - video.muted = muted; - void initPlayer(); return () => { + video.removeEventListener("loadedmetadata", onLoadedMetadata); + video.removeEventListener("loadeddata", onLoadedData); + video.removeEventListener("canplay", onCanPlay); + video.removeEventListener("playing", onPlaying); video.removeEventListener("waiting", onWaiting); video.removeEventListener("stalled", onStalled); - video.removeEventListener("loadedmetadata", onLoadedMetadata); + video.removeEventListener("timeupdate", onTimeUpdate); video.removeEventListener("error", onVideoError); - clearTimers(); + clearRetry(); + clearLatencyInterval(); disposed = true; if (hls) { hls.destroy(); hls = null; } + video.removeAttribute("src"); + video.load(); }; - }, [embedUrl, streamUrl, autoPlay, muted]); + }, [ + embedUrl, + streamUrl, + autoPlay, + muted, + markDegraded, + markReady, + markUnavailable, + ]); + const overlayMessage = embedFailure ?? diagnosticMessage; if (!embedUrl) { return ( @@ -323,6 +600,42 @@ export const StreamPlayer: React.FC = ({ backgroundColor: "#000", }} /> + {overlayMessage ? ( +
+
+ {overlayMessage} +
+
+ ) : null} +
= ({ className={className} style={{ position: "relative", width: "100%", height: "100%", ...style }} > -