diff --git a/roll-dice/anchor/app/app/delegated/page.tsx b/roll-dice/anchor/app/app/delegated/page.tsx index 5941eae..e7f3e64 100644 --- a/roll-dice/anchor/app/app/delegated/page.tsx +++ b/roll-dice/anchor/app/app/delegated/page.tsx @@ -50,11 +50,16 @@ import { loadIdl, } from "@/lib/solana-utils" import type { RollEntry, CachedBlockhash } from "@/lib/types" +import { + requiresLiveRollCorrelation, + shouldCompleteRoll, + type RollResultSource, +} from "@/lib/roll-result" const derivePlayerPda = (user: PublicKey) => PublicKey.findProgramAddressSync([Buffer.from(PLAYER_SEED), user.toBuffer()], PROGRAM_ID)[0] -type PlayerAccountSource = "subscription" | "sync" | "poll" | "callback" +type PlayerAccountSource = RollResultSource const ROLL_FALLBACK_POLL_INITIAL_MS = 250 const ROLL_RESULT_DEADLINE_MS = 30000 @@ -197,6 +202,8 @@ export default function DiceRollerDelegated() { const lastObservedSlotRef = useRef(null) const pendingRollRef = useRef(false) const pendingRollGenerationRef = useRef(0) + const pendingStartRollnumRef = useRef(null) + const pendingClientSeedRef = useRef(null) const pendingRequestSignatureRef = useRef(null) const pendingRequestSlotRef = useRef(null) const unavailableClientSeedsRef = useRef>(new Set()) @@ -223,6 +230,8 @@ export default function DiceRollerDelegated() { const cachedEphemeralBlockhashRef = useRef(null) const clearRequestTracking = useCallback(() => { + pendingStartRollnumRef.current = null + pendingClientSeedRef.current = null pendingRequestSignatureRef.current = null pendingRequestSlotRef.current = null }, []) @@ -303,12 +312,22 @@ export default function DiceRollerDelegated() { (newRollnum < lastObservedRollnum || (source === "poll" && newRollnum === lastObservedRollnum))) ) return - const completesPendingRoll = source === "callback" && - rollGeneration === pendingRollGenerationRef.current && - pendingRollRef.current && - newValue > 0 + const completesPendingRoll = shouldCompleteRoll({ + source, + isPending: pendingRollRef.current, + activeGeneration: pendingRollGenerationRef.current, + observedGeneration: rollGeneration, + startRollnum: pendingStartRollnumRef.current, + newRollnum, + newValue, + hasRequestSignature: pendingRequestSignatureRef.current !== null, + requestSlot: pendingRequestSlotRef.current, + observedSlot: slot, + }) if (completesPendingRoll) { + const clientSeed = pendingClientSeedRef.current + if (clientSeed !== null) unavailableClientSeedsRef.current.delete(clientSeed) pendingRollRef.current = false pendingRollGenerationRef.current += 1 clearRequestTracking() @@ -980,6 +999,12 @@ export default function DiceRollerDelegated() { let requestSignature: string | null = null pendingRollGenerationRef.current = rollGeneration + pendingStartRollnumRef.current = lastObservedRollnumRef.current + pendingClientSeedRef.current = randomValue + const needsLiveCorrelation = requiresLiveRollCorrelation( + pendingStartRollnumRef.current, + unavailableClientSeedsRef.current.size, + ) pendingRollRef.current = true setIsRolling(true) setIsAwaitingResult(true) @@ -1056,42 +1081,44 @@ export default function DiceRollerDelegated() { }, ROLL_RESULT_DEADLINE_MS) try { - try { - const id = connection.onLogs( - playerPda, - (info, context) => { - if ( - info.err || - !info.logs.includes(callbackInstructionLog) || - !info.logs.includes(callbackSeedLog) - ) return - - const observedAt = Date.now() - void (async () => { - const signature = requestSignature - if (!signature || !isCurrentRequest()) return - - const requestSlot = pendingRequestSlotRef.current - if (requestSlot !== null && context.slot < requestSlot) return - - unavailableClientSeedsRef.current.delete(randomValue) - await refreshPlayerAccount( - connection, - "callback", - connectionGeneration, - rollGeneration, - context.slot, - observedAt, - ) - })().catch(error => { - console.error("[RollDice] Callback account refresh failed:", error) - }) - }, - "processed", - ) - callbackLogsSubscriptionRef.current = { connection, id } - } catch (error) { - console.error("[RollDice] Callback log subscription failed; using history fallback:", error) + if (needsLiveCorrelation) { + try { + const id = connection.onLogs( + playerPda, + (info, context) => { + if ( + info.err || + !info.logs.includes(callbackInstructionLog) || + !info.logs.includes(callbackSeedLog) + ) return + + const observedAt = Date.now() + void (async () => { + const signature = requestSignature + if (!signature || !isCurrentRequest()) return + + const requestSlot = pendingRequestSlotRef.current + if (requestSlot !== null && context.slot < requestSlot) return + + unavailableClientSeedsRef.current.delete(randomValue) + await refreshPlayerAccount( + connection, + "callback", + connectionGeneration, + rollGeneration, + context.slot, + observedAt, + ) + })().catch(error => { + console.error("[RollDice] Callback account refresh failed:", error) + }) + }, + "processed", + ) + callbackLogsSubscriptionRef.current = { connection, id } + } catch (error) { + console.error("[RollDice] Callback log subscription failed; using history fallback:", error) + } } const [tx, latestBlockhash] = await Promise.all([ @@ -1116,7 +1143,14 @@ export default function DiceRollerDelegated() { const transactionStartTime = Date.now() const signature = anchor.utils.bytes.bs58.encode(tx.signature) requestSignature = signature - trackRequestSlot(connection, signature) + if (needsLiveCorrelation) { + trackRequestSlot(connection, signature) + } else { + // Ordinary rolls need only the existing account subscription. If it is + // missed, fallback polling records the request slot before reconciling + // the seed-correlated callback from transaction history. + pendingRequestSignatureRef.current = signature + } setRollHistory(prev => { const idx = prev.findIndex(entry => entry.isPending) if (idx === -1) return prev @@ -1229,7 +1263,8 @@ export default function DiceRollerDelegated() { }) }, ROLL_TIMEOUT_MS) - // Callback logs are primary; guarded reads and transaction history hedge delayed WebSocket delivery. + // Account changes are the fast path while rollnum can advance. Correlated + // callback logs and history remain the fallback for saturated counters. resultPollTimeoutRef.current = setTimeout(pollForResult, nextPollDelay) nextPollDelay *= 2 diff --git a/roll-dice/anchor/app/lib/roll-result.test.ts b/roll-dice/anchor/app/lib/roll-result.test.ts new file mode 100644 index 0000000..eb453df --- /dev/null +++ b/roll-dice/anchor/app/lib/roll-result.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { + requiresLiveRollCorrelation, + shouldCompleteRoll, + type RollResultSource, +} from "./roll-result.ts" + +const completion = ( + overrides: Partial[0]> = {}, +) => shouldCompleteRoll({ + source: "subscription", + isPending: true, + activeGeneration: 4, + startRollnum: 12, + newRollnum: 13, + newValue: 6, + hasRequestSignature: true, + requestSlot: null, + observedSlot: 101, + ...overrides, +}) + +describe("shouldCompleteRoll", () => { + it("uses a rollnum-advancing account subscription as the fast path", () => { + assert.equal(completion(), true) + }) + + it("rejects stale, unchanged, and pre-request subscription updates", () => { + assert.equal(completion({ newRollnum: 12 }), false) + assert.equal(completion({ newRollnum: 11 }), false) + assert.equal(completion({ hasRequestSignature: false }), false) + assert.equal(completion({ startRollnum: null }), false) + assert.equal(completion({ requestSlot: 102, observedSlot: 101 }), false) + assert.equal(completion({ requestSlot: 101, observedSlot: 101 }), true) + }) + + it("does not let sync, polling, or warm-up updates complete a user roll", () => { + for (const source of ["sync", "poll"] satisfies RollResultSource[]) { + assert.equal(completion({ source }), false) + } + assert.equal(completion({ isPending: false }), false) + }) + + it("keeps a saturated counter on the correlated callback path", () => { + assert.equal(completion({ startRollnum: 255, newRollnum: 255 }), false) + }) + + it("requires the callback to match the active roll generation", () => { + assert.equal(completion({ + source: "callback", + observedGeneration: 4, + startRollnum: 255, + newRollnum: 255, + }), true) + assert.equal(completion({ + source: "callback", + observedGeneration: 3, + startRollnum: 255, + newRollnum: 255, + }), false) + }) +}) + +describe("requiresLiveRollCorrelation", () => { + it("keeps ordinary single-in-flight rolls on the account-only hot path", () => { + assert.equal(requiresLiveRollCorrelation(96, 1), false) + }) + + it("enables live correlation for ambiguous account updates", () => { + assert.equal(requiresLiveRollCorrelation(null, 1), true) + assert.equal(requiresLiveRollCorrelation(255, 1), true) + assert.equal(requiresLiveRollCorrelation(96, 2), true) + }) +}) diff --git a/roll-dice/anchor/app/lib/roll-result.ts b/roll-dice/anchor/app/lib/roll-result.ts new file mode 100644 index 0000000..1018264 --- /dev/null +++ b/roll-dice/anchor/app/lib/roll-result.ts @@ -0,0 +1,63 @@ +export type RollResultSource = "subscription" | "sync" | "poll" | "callback" + +export function requiresLiveRollCorrelation( + startRollnum: number | null, + unavailableClientSeeds: number, +): boolean { + return startRollnum === null || + startRollnum >= 255 || + unavailableClientSeeds > 1 +} + +type RollResultCompletion = { + source: RollResultSource + isPending: boolean + activeGeneration: number + observedGeneration?: number + startRollnum: number | null + newRollnum: number + newValue: number + hasRequestSignature: boolean + requestSlot: number | null + observedSlot?: number +} + +/** + * Account subscriptions are the lowest-latency signal, but the player account + * does not store the callback's client seed. In the app's single-in-flight + * flow, a rollnum increment is the account-state transition we can use + * directly without another RPC round trip. + * + * Once the u8 counter reaches 255, completion stays on the seed-correlated + * callback path. + */ +export function shouldCompleteRoll({ + source, + isPending, + activeGeneration, + observedGeneration, + startRollnum, + newRollnum, + newValue, + hasRequestSignature, + requestSlot, + observedSlot, +}: RollResultCompletion): boolean { + if (!isPending || newValue <= 0) return false + + if (source === "callback") { + return observedGeneration === activeGeneration + } + + if ( + source !== "subscription" || + !hasRequestSignature || + startRollnum === null || + startRollnum >= 255 || + newRollnum <= startRollnum + ) return false + + return requestSlot === null || + observedSlot === undefined || + observedSlot >= requestSlot +} diff --git a/roll-dice/anchor/app/package.json b/roll-dice/anchor/app/package.json index 125b35e..d0f9215 100644 --- a/roll-dice/anchor/app/package.json +++ b/roll-dice/anchor/app/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test:roll-result": "node --test --experimental-strip-types lib/roll-result.test.ts" }, "dependencies": { "@hookform/resolvers": "^3.9.1", diff --git a/roll-dice/anchor/app/tsconfig.json b/roll-dice/anchor/app/tsconfig.json index 4b2dc7b..da37e90 100644 --- a/roll-dice/anchor/app/tsconfig.json +++ b/roll-dice/anchor/app/tsconfig.json @@ -9,6 +9,7 @@ "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve",