Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 78 additions & 43 deletions roll-dice/anchor/app/app/delegated/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -197,6 +202,8 @@ export default function DiceRollerDelegated() {
const lastObservedSlotRef = useRef<number | null>(null)
const pendingRollRef = useRef(false)
const pendingRollGenerationRef = useRef(0)
const pendingStartRollnumRef = useRef<number | null>(null)
const pendingClientSeedRef = useRef<number | null>(null)
const pendingRequestSignatureRef = useRef<string | null>(null)
const pendingRequestSlotRef = useRef<number | null>(null)
const unavailableClientSeedsRef = useRef<Set<number>>(new Set())
Expand All @@ -223,6 +230,8 @@ export default function DiceRollerDelegated() {
const cachedEphemeralBlockhashRef = useRef<CachedBlockhash | null>(null)

const clearRequestTracking = useCallback(() => {
pendingStartRollnumRef.current = null
pendingClientSeedRef.current = null
pendingRequestSignatureRef.current = null
pendingRequestSlotRef.current = null
}, [])
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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([
Expand 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
Expand Down Expand Up @@ -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

Expand Down
75 changes: 75 additions & 0 deletions roll-dice/anchor/app/lib/roll-result.test.ts
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof shouldCompleteRoll>[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)
})
})
63 changes: 63 additions & 0 deletions roll-dice/anchor/app/lib/roll-result.ts
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 2 additions & 1 deletion roll-dice/anchor/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions roll-dice/anchor/app/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
Expand Down
Loading