From d71001a7b8c86cbe1a8ae5f90d1a4367dd0dc26d Mon Sep 17 00:00:00 2001
From: CryptoMickle <318943357+CryptoMickle@users.noreply.github.com>
Date: Tue, 1 Sep 2026 20:35:53 +0200
Subject: [PATCH 1/7] perf: reduce Somnia VRF frontend latency
---
frontend/app/page.tsx | 52 +++++++++++++++++++++++++++++++++++++++----
1 file changed, 48 insertions(+), 4 deletions(-)
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 6d41536..90e7e88 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -3046,6 +3046,7 @@ function DelvewornGame() {
useRef<{
name: string;
startedAt: number;
+ lastStageAt: number;
} | null>(null);
const bossRewardRef =
@@ -3067,9 +3068,15 @@ function DelvewornGame() {
return;
}
+ const now =
+ runtimeNowMs();
+
console.info(
- `[${ACTIVE_ECOSYSTEM_NAME.toUpperCase()} TIMING] ${timing.name} ${stage}: +${runtimeNowMs() - timing.startedAt}ms`
+ `[${ACTIVE_ECOSYSTEM_NAME.toUpperCase()} TIMING] ${timing.name} ${stage}: +${now - timing.startedAt}ms total, +${now - timing.lastStageAt}ms stage`
);
+
+ timing.lastStageAt =
+ now;
}
useEffect(() => {
@@ -3580,7 +3587,10 @@ function DelvewornGame() {
},
pollingInterval:
- 500,
+ Math.max(
+ TESTNET_POLLING_MS,
+ 250
+ ),
onLogs:
(
@@ -5799,6 +5809,9 @@ function DelvewornGame() {
functionName:
SessionAction,
+ waitForStatus =
+ true,
+
args:
readonly unknown[] = []
): Promise<
@@ -5835,6 +5848,27 @@ function DelvewornGame() {
data
);
+ timingLog(
+ "Somnia smart-account transaction confirmed"
+ );
+
+ if (!waitForStatus) {
+ /*
+ Thirdweb's smart-account sender has already waited for the ERC-4337
+ user operation receipt before returning the transaction hash. VRF
+ actions are completed by the lean RandomnessFulfilled watcher, so a
+ second HTTP receipt lookup only delays the animation without adding a
+ stronger confirmation signal.
+ */
+ return {
+ hash:
+ result.transactionHash,
+ logs: [],
+ bundleId:
+ "",
+ };
+ }
+
const receipt =
await waitForReceipt(
result.transactionHash
@@ -5898,6 +5932,7 @@ function DelvewornGame() {
) {
return sendDungeonSomniaSessionCall(
functionName,
+ waitForStatus,
args
);
}
@@ -6736,9 +6771,15 @@ function DelvewornGame() {
return;
}
+ const actionStartedAt =
+ runtimeNowMs();
+
actionTimingRef.current = {
name: functionName,
- startedAt: runtimeNowMs(),
+ startedAt:
+ actionStartedAt,
+ lastStageAt:
+ actionStartedAt,
};
timingLog(
@@ -6856,7 +6897,10 @@ function DelvewornGame() {
);
timingLog(
- "sendDungeonSessionCall returned"
+ expectedRequestKind ===
+ RequestKind.None
+ ? "transaction flow completed"
+ : "transaction confirmed; VRF wait active"
);
transactionSubmitted =
From d0b6cd53b65f7f10f24c662434e9b7b2570958e7 Mon Sep 17 00:00:00 2001
From: CryptoMickle <318943357+CryptoMickle@users.noreply.github.com>
Date: Tue, 1 Sep 2026 21:30:29 +0200
Subject: [PATCH 2/7] perf: add Somnia bundler latency benchmark
---
frontend/app/page.tsx | 279 +++++++++++++++++++++++++++++++++++++++++-
1 file changed, 274 insertions(+), 5 deletions(-)
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 90e7e88..b19ac93 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -2072,6 +2072,29 @@ type VrfCacheEntry = {
receivedAt: number;
};
+type LatencyBenchmarkSample = {
+ id: string;
+ action: string;
+ mode: string;
+ submissionMs: number;
+ vrfMs: number;
+ stateSyncMs: number;
+ totalMs: number;
+};
+
+type ActionTiming = {
+ name: string;
+ mode: string;
+ expectedRequestKind: number;
+ startedAt: number;
+ lastStageAt: number;
+ submissionConfirmedAt: number | null;
+ vrfEventReceivedAt: number | null;
+ stateReadStartedAt: number | null;
+ stateReadCompletedAt: number | null;
+ sampleCompleted: boolean;
+};
+
/*
============================================================
GENERIC HELPERS
@@ -3017,6 +3040,14 @@ function DelvewornGame() {
] =
useState(false);
+ const [
+ latencySamples,
+ setLatencySamples,
+ ] =
+ useState<
+ LatencyBenchmarkSample[]
+ >([]);
+
const canonicalRecoveryRef =
useRef(false);
@@ -3043,11 +3074,9 @@ function DelvewornGame() {
>(null);
const actionTimingRef =
- useRef<{
- name: string;
- startedAt: number;
- lastStageAt: number;
- } | null>(null);
+ useRef<
+ ActionTiming | null
+ >(null);
const bossRewardRef =
useRef(
@@ -3079,6 +3108,92 @@ function DelvewornGame() {
now;
}
+ function completeLatencyBenchmark() {
+ const timing =
+ actionTimingRef.current;
+
+ if (
+ !timing ||
+ timing.sampleCompleted ||
+ timing.expectedRequestKind ===
+ RequestKind.None
+ ) {
+ return;
+ }
+
+ const completedAt =
+ runtimeNowMs();
+
+ const submissionConfirmedAt =
+ timing.submissionConfirmedAt ??
+ completedAt;
+
+ const vrfEventReceivedAt =
+ timing.vrfEventReceivedAt ??
+ timing.stateReadCompletedAt ??
+ completedAt;
+
+ const stateReadStartedAt =
+ timing.stateReadStartedAt ??
+ vrfEventReceivedAt;
+
+ const stateReadCompletedAt =
+ timing.stateReadCompletedAt ??
+ completedAt;
+
+ const sample:
+ LatencyBenchmarkSample = {
+ id:
+ `${timing.startedAt}-${timing.name}`,
+ action:
+ timing.name,
+ mode:
+ timing.mode,
+ submissionMs:
+ Math.max(
+ 0,
+ submissionConfirmedAt -
+ timing.startedAt
+ ),
+ vrfMs:
+ Math.max(
+ 0,
+ vrfEventReceivedAt -
+ submissionConfirmedAt
+ ),
+ stateSyncMs:
+ Math.max(
+ 0,
+ stateReadCompletedAt -
+ stateReadStartedAt
+ ),
+ totalMs:
+ Math.max(
+ 0,
+ completedAt -
+ timing.startedAt
+ ),
+ };
+
+ timing.sampleCompleted =
+ true;
+
+ setLatencySamples(
+ (samples) => [
+ sample,
+ ...samples,
+ ].slice(
+ 0,
+ 8
+ )
+ );
+
+ console.info(
+ `[${ACTIVE_ECOSYSTEM_NAME.toUpperCase()} BENCHMARK]`,
+ sample
+ );
+ }
+
useEffect(() => {
connectedAddressRef.current =
connectedAddress;
@@ -3455,6 +3570,22 @@ function DelvewornGame() {
entry
);
+ const actionTiming =
+ actionTimingRef.current;
+
+ if (
+ actionTiming &&
+ actionTiming.vrfEventReceivedAt ===
+ null &&
+ actionTiming.expectedRequestKind ===
+ entry.kind &&
+ entry.receivedAt >=
+ actionTiming.startedAt
+ ) {
+ actionTiming.vrfEventReceivedAt =
+ entry.receivedAt;
+ }
+
timingLog(
`RandomnessFulfilled received via ${source} (request ${requestId.toString()}, kind ${Number(kind)})`
);
@@ -3875,6 +4006,8 @@ function DelvewornGame() {
stage
);
+ completeLatencyBenchmark();
+
void finalizeCanonicalAction(
playerAddress,
displayStartedAt,
@@ -4116,6 +4249,18 @@ function DelvewornGame() {
playerAddress.toLowerCase()
) {
try {
+ const actionTiming =
+ actionTimingRef.current;
+
+ if (
+ actionTiming &&
+ actionTiming.stateReadStartedAt ===
+ null
+ ) {
+ actionTiming.stateReadStartedAt =
+ runtimeNowMs();
+ }
+
timingLog(
`starting state read via ${cached.source}`
);
@@ -4130,6 +4275,11 @@ function DelvewornGame() {
`state read via ${cached.source} completed`
);
+ if (actionTiming) {
+ actionTiming.stateReadCompletedAt =
+ runtimeNowMs();
+ }
+
if (
resolvedState.pendingRequestId ===
BigInt(0)
@@ -6776,10 +6926,29 @@ function DelvewornGame() {
actionTimingRef.current = {
name: functionName,
+ mode:
+ isRiseWallet
+ ? "RISE Instant Play"
+ : somniaSessionMode &&
+ hasSomniaSession
+ ? "Somnia Instant Play"
+ : "MetaMask Standard Play",
+ expectedRequestKind:
+ RequestKind.None,
startedAt:
actionStartedAt,
lastStageAt:
actionStartedAt,
+ submissionConfirmedAt:
+ null,
+ vrfEventReceivedAt:
+ null,
+ stateReadStartedAt:
+ null,
+ stateReadCompletedAt:
+ null,
+ sampleCompleted:
+ false,
};
timingLog(
@@ -6869,6 +7038,13 @@ function DelvewornGame() {
expectedRequestKind !==
RequestKind.None
) {
+ if (
+ actionTimingRef.current
+ ) {
+ actionTimingRef.current.expectedRequestKind =
+ expectedRequestKind;
+ }
+
setActionReady(
false
);
@@ -6896,6 +7072,13 @@ function DelvewornGame() {
RequestKind.None
);
+ if (
+ actionTimingRef.current
+ ) {
+ actionTimingRef.current.submissionConfirmedAt =
+ runtimeNowMs();
+ }
+
timingLog(
expectedRequestKind ===
RequestKind.None
@@ -8340,6 +8523,92 @@ function DelvewornGame() {
)}
+ {ACTIVE_ECOSYSTEM_NAME ===
+ "Somnia" && (
+
+
+
+
+ BUNDLER + VRF BENCHMARK
+
+
+ Instant submit includes signing, sponsorship, bundler and confirmation. Standard submit also includes the wallet approval.
+
+
+
+ {latencySamples.length >
+ 0 && (
+
+ setLatencySamples(
+ []
+ )
+ }
+ className="text-[9px] text-zinc-500 underline transition hover:text-zinc-300"
+ >
+ clear
+
+ )}
+
+
+ {latencySamples.length ===
+ 0 ? (
+
+ Run Attack, Storm, Potion or enter a room to record the first sample.
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+ {latencySamples[0].mode} · {latencySamples[0].action} · show history
+
+
+ {latencySamples.map(
+ (sample) => (
+
+ {sample.mode} · {sample.action}: submit {(sample.submissionMs / 1_000).toFixed(2)}s · VRF {(sample.vrfMs / 1_000).toFixed(2)}s · state {(sample.stateSyncMs / 1_000).toFixed(2)}s · total {(sample.totalMs / 1_000).toFixed(2)}s
+
+ )
+ )}
+
+
+ >
+ )}
+
+ )}
+
{/* ===================================================
COMPACT STICKY HUD
=================================================== */}
From 2c376d4f25097eb0f04c53cd0a1a3c1a176808f0 Mon Sep 17 00:00:00 2001
From: CryptoMickle <318943357+CryptoMickle@users.noreply.github.com>
Date: Wed, 2 Sep 2026 00:44:07 +0200
Subject: [PATCH 3/7] perf: split Somnia smart-account latency stages
---
frontend/app/page.tsx | 72 ++++++++++++----
frontend/app/somnia-session-keys.ts | 124 +++++++++++++++++++++++++++-
2 files changed, 178 insertions(+), 18 deletions(-)
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index b19ac93..2fb1469 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -2076,18 +2076,30 @@ type LatencyBenchmarkSample = {
id: string;
action: string;
mode: string;
+ smartAccount: SmartAccountLatencyBreakdown | null;
submissionMs: number;
vrfMs: number;
stateSyncMs: number;
totalMs: number;
};
+type SmartAccountLatencyBreakdown = {
+ preparationMs: number;
+ gasEstimationMs: number;
+ paymasterMs: number;
+ bundlerSubmissionMs: number;
+ inclusionWaitMs: number;
+ receiptPollCount: number;
+ totalMs: number;
+};
+
type ActionTiming = {
name: string;
mode: string;
expectedRequestKind: number;
startedAt: number;
lastStageAt: number;
+ smartAccount: SmartAccountLatencyBreakdown | null;
submissionConfirmedAt: number | null;
vrfEventReceivedAt: number | null;
stateReadStartedAt: number | null;
@@ -3149,6 +3161,8 @@ function DelvewornGame() {
timing.name,
mode:
timing.mode,
+ smartAccount:
+ timing.smartAccount,
submissionMs:
Math.max(
0,
@@ -5998,6 +6012,11 @@ function DelvewornGame() {
data
);
+ if (actionTimingRef.current) {
+ actionTimingRef.current.smartAccount =
+ result.benchmark;
+ }
+
timingLog(
"Somnia smart-account transaction confirmed"
);
@@ -6939,6 +6958,8 @@ function DelvewornGame() {
actionStartedAt,
lastStageAt:
actionStartedAt,
+ smartAccount:
+ null,
submissionConfirmedAt:
null,
vrfEventReceivedAt:
@@ -8532,7 +8553,7 @@ function DelvewornGame() {
BUNDLER + VRF BENCHMARK
- Instant submit includes signing, sponsorship, bundler and confirmation. Standard submit also includes the wallet approval.
+ Instant Play split follows Thirdweb's ERC-4337 flow. Prepare includes local work and gas estimation; inclusion is the wait after bundler submission.
@@ -8559,25 +8580,35 @@ function DelvewornGame() {
) : (
<>
-
+
+
+
@@ -8596,11 +8627,18 @@ function DelvewornGame() {
{latencySamples.map(
- (sample) => (
+ (sample) => {
+ const smart =
+ sample.smartAccount;
+
+ return (
- {sample.mode} · {sample.action}: submit {(sample.submissionMs / 1_000).toFixed(2)}s · VRF {(sample.vrfMs / 1_000).toFixed(2)}s · state {(sample.stateSyncMs / 1_000).toFixed(2)}s · total {(sample.totalMs / 1_000).toFixed(2)}s
+ {sample.mode} · {sample.action}: {smart
+ ? `prep ${(smart.preparationMs / 1_000).toFixed(2)}s (estimate ${(smart.gasEstimationMs / 1_000).toFixed(2)}s) · paymaster ${(smart.paymasterMs / 1_000).toFixed(2)}s · bundler ${(smart.bundlerSubmissionMs / 1_000).toFixed(2)}s · inclusion ${(smart.inclusionWaitMs / 1_000).toFixed(2)}s (${smart.receiptPollCount} polls) · `
+ : `submit ${(sample.submissionMs / 1_000).toFixed(2)}s · `}VRF {(sample.vrfMs / 1_000).toFixed(2)}s · state {(sample.stateSyncMs / 1_000).toFixed(2)}s · total {(sample.totalMs / 1_000).toFixed(2)}s
- )
+ );
+ }
)}
diff --git a/frontend/app/somnia-session-keys.ts b/frontend/app/somnia-session-keys.ts
index 932017a..28c999c 100644
--- a/frontend/app/somnia-session-keys.ts
+++ b/frontend/app/somnia-session-keys.ts
@@ -35,6 +35,40 @@ export type SomniaSessionHandle = {
record: SomniaSessionRecord;
};
+export type SomniaSessionTransactionBenchmark = {
+ preparationMs: number;
+ gasEstimationMs: number;
+ paymasterMs: number;
+ bundlerSubmissionMs: number;
+ inclusionWaitMs: number;
+ receiptPollCount: number;
+ totalMs: number;
+};
+
+function benchmarkNowMs() {
+ return globalThis.performance?.now() ?? Date.now();
+}
+
+function jsonRpcMethod(
+ body: BodyInit | null | undefined
+): string | null {
+ if (typeof body !== "string") {
+ return null;
+ }
+
+ try {
+ const payload = JSON.parse(body) as {
+ method?: unknown;
+ };
+
+ return typeof payload.method === "string"
+ ? payload.method
+ : null;
+ } catch {
+ return null;
+ }
+}
+
const somniaChain = defineChain({
id: activeDeployment.chain.id,
name: activeDeployment.chain.name,
@@ -236,8 +270,96 @@ export async function sendSomniaSessionTransaction(
data,
value: BigInt(0),
});
+ const startedAt = benchmarkNowMs();
+ const originalFetch = globalThis.fetch;
+ let gasEstimationMs = 0;
+ let paymasterMs = 0;
+ let bundlerSubmissionMs = 0;
+ let receiptPollCount = 0;
+ let userOpSubmissionStartedAt: number | null = null;
+ let userOpSubmittedAt: number | null = null;
+
+ const benchmarkFetch: typeof globalThis.fetch = async (
+ input,
+ init
+ ) => {
+ const method = jsonRpcMethod(init?.body);
+ const requestStartedAt = benchmarkNowMs();
+
+ if (
+ method === "eth_sendUserOperation" &&
+ userOpSubmissionStartedAt === null
+ ) {
+ userOpSubmissionStartedAt = requestStartedAt;
+ }
- return sendTransaction({ account, transaction });
+ if (method === "eth_getUserOperationReceipt") {
+ receiptPollCount += 1;
+ }
+
+ try {
+ return await originalFetch(input, init);
+ } finally {
+ const requestCompletedAt = benchmarkNowMs();
+ const requestMs = Math.max(
+ 0,
+ requestCompletedAt - requestStartedAt
+ );
+
+ if (method === "eth_estimateUserOperationGas") {
+ gasEstimationMs += requestMs;
+ }
+
+ if (method === "pm_sponsorUserOperation") {
+ paymasterMs += requestMs;
+ }
+
+ if (method === "eth_sendUserOperation") {
+ bundlerSubmissionMs += requestMs;
+ userOpSubmittedAt = requestCompletedAt;
+ }
+ }
+ };
+
+ globalThis.fetch = benchmarkFetch;
+
+ try {
+ const result = await sendTransaction({ account, transaction });
+ const completedAt = benchmarkNowMs();
+ const totalMs = Math.max(0, completedAt - startedAt);
+ const preparationWindowMs = Math.max(
+ 0,
+ (userOpSubmissionStartedAt ?? completedAt) - startedAt
+ );
+ const preparationMs = Math.max(
+ 0,
+ preparationWindowMs - paymasterMs
+ );
+ const inclusionWaitMs = Math.max(
+ 0,
+ userOpSubmittedAt === null
+ ? 0
+ : completedAt - userOpSubmittedAt
+ );
+ const benchmark: SomniaSessionTransactionBenchmark = {
+ preparationMs,
+ gasEstimationMs,
+ paymasterMs,
+ bundlerSubmissionMs,
+ inclusionWaitMs,
+ receiptPollCount,
+ totalMs,
+ };
+
+ return {
+ ...result,
+ benchmark,
+ };
+ } finally {
+ if (globalThis.fetch === benchmarkFetch) {
+ globalThis.fetch = originalFetch;
+ }
+ }
}
export async function revokeSomniaSession(
From b821619d98e9b799b2e68d83ab79779a8e6e1ab5 Mon Sep 17 00:00:00 2001
From: CryptoMickle <318943357+CryptoMickle@users.noreply.github.com>
Date: Wed, 2 Sep 2026 01:36:08 +0200
Subject: [PATCH 4/7] perf: poll Somnia user operations faster
---
frontend/app/page.tsx | 7 +++--
frontend/app/somnia-session-keys.ts | 48 ++++++++++++++++++++++++++---
2 files changed, 48 insertions(+), 7 deletions(-)
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 2fb1469..f6ad4a1 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -2090,6 +2090,7 @@ type SmartAccountLatencyBreakdown = {
bundlerSubmissionMs: number;
inclusionWaitMs: number;
receiptPollCount: number;
+ receiptPollingIntervalMs: number;
totalMs: number;
};
@@ -6008,7 +6009,7 @@ function DelvewornGame() {
const result =
await sendSomniaSessionTransaction(
- somniaSessionHandle.account,
+ somniaSessionHandle.record,
data
);
@@ -8553,7 +8554,7 @@ function DelvewornGame() {
BUNDLER + VRF BENCHMARK
- Instant Play split follows Thirdweb's ERC-4337 flow. Prepare includes local work and gas estimation; inclusion is the wait after bundler submission.
+ Instant Play split follows Thirdweb's ERC-4337 flow. Prepare includes local work and gas estimation; inclusion uses fast receipt polling after bundler submission.
@@ -8634,7 +8635,7 @@ function DelvewornGame() {
return (
{sample.mode} · {sample.action}: {smart
- ? `prep ${(smart.preparationMs / 1_000).toFixed(2)}s (estimate ${(smart.gasEstimationMs / 1_000).toFixed(2)}s) · paymaster ${(smart.paymasterMs / 1_000).toFixed(2)}s · bundler ${(smart.bundlerSubmissionMs / 1_000).toFixed(2)}s · inclusion ${(smart.inclusionWaitMs / 1_000).toFixed(2)}s (${smart.receiptPollCount} polls) · `
+ ? `prep ${(smart.preparationMs / 1_000).toFixed(2)}s (estimate ${(smart.gasEstimationMs / 1_000).toFixed(2)}s) · paymaster ${(smart.paymasterMs / 1_000).toFixed(2)}s · bundler ${(smart.bundlerSubmissionMs / 1_000).toFixed(2)}s · inclusion ${(smart.inclusionWaitMs / 1_000).toFixed(2)}s (${smart.receiptPollCount} polls @ ${smart.receiptPollingIntervalMs}ms) · `
: `submit ${(sample.submissionMs / 1_000).toFixed(2)}s · `}VRF {(sample.vrfMs / 1_000).toFixed(2)}s · state {(sample.stateSyncMs / 1_000).toFixed(2)}s · total {(sample.totalMs / 1_000).toFixed(2)}s
);
diff --git a/frontend/app/somnia-session-keys.ts b/frontend/app/somnia-session-keys.ts
index 28c999c..ea137d9 100644
--- a/frontend/app/somnia-session-keys.ts
+++ b/frontend/app/somnia-session-keys.ts
@@ -10,7 +10,12 @@ import {
} from "thirdweb/extensions/erc4337";
import { createWallet, type Account } from "thirdweb/wallets";
import { privateKeyToAccount } from "thirdweb/wallets/private-key";
-import { smartWallet } from "thirdweb/wallets/smart";
+import {
+ bundleUserOp,
+ createAndSignUserOp,
+ smartWallet,
+ waitForUserOpReceipt,
+} from "thirdweb/wallets/smart";
import {
prepareTransaction,
sendTransaction,
@@ -29,6 +34,7 @@ import {
} from "./somnia-session-storage";
const CLOCK_SKEW_MS = 30_000;
+const USER_OPERATION_RECEIPT_POLL_INTERVAL_MS = 250;
export type SomniaSessionHandle = {
account: Account;
@@ -42,6 +48,7 @@ export type SomniaSessionTransactionBenchmark = {
bundlerSubmissionMs: number;
inclusionWaitMs: number;
receiptPollCount: number;
+ receiptPollingIntervalMs: number;
totalMs: number;
};
@@ -259,10 +266,21 @@ export async function createSomniaSession(
}
export async function sendSomniaSessionTransaction(
- account: Account,
+ record: SomniaSessionRecord,
data: Hex
) {
const client = thirdwebClient();
+ const sessionSigner = privateKeyToAccount({
+ client,
+ privateKey: record.sessionPrivateKey,
+ });
+ const smartWalletOptions = {
+ chain: somniaChain,
+ sponsorGas: true,
+ overrides: {
+ accountAddress: record.smartAccountAddress,
+ },
+ } as const;
const transaction = prepareTransaction({
client,
chain: somniaChain,
@@ -324,7 +342,25 @@ export async function sendSomniaSessionTransaction(
globalThis.fetch = benchmarkFetch;
try {
- const result = await sendTransaction({ account, transaction });
+ const signedUserOp = await createAndSignUserOp({
+ transactions: [transaction],
+ adminAccount: sessionSigner,
+ client,
+ smartWalletOptions,
+ });
+ const bundlerOptions = {
+ chain: somniaChain,
+ client,
+ };
+ const userOpHash = await bundleUserOp({
+ userOp: signedUserOp,
+ options: bundlerOptions,
+ });
+ const receipt = await waitForUserOpReceipt({
+ ...bundlerOptions,
+ userOpHash,
+ intervalMs: USER_OPERATION_RECEIPT_POLL_INTERVAL_MS,
+ });
const completedAt = benchmarkNowMs();
const totalMs = Math.max(0, completedAt - startedAt);
const preparationWindowMs = Math.max(
@@ -348,11 +384,15 @@ export async function sendSomniaSessionTransaction(
bundlerSubmissionMs,
inclusionWaitMs,
receiptPollCount,
+ receiptPollingIntervalMs:
+ USER_OPERATION_RECEIPT_POLL_INTERVAL_MS,
totalMs,
};
return {
- ...result,
+ chain: somniaChain,
+ client,
+ transactionHash: receipt.transactionHash,
benchmark,
};
} finally {
From 6690d169a83ee446a54711c61ce794d4842e5b31 Mon Sep 17 00:00:00 2001
From: CryptoMickle <318943357+CryptoMickle@users.noreply.github.com>
Date: Wed, 2 Sep 2026 02:38:40 +0200
Subject: [PATCH 5/7] perf: add Somnia VRF websocket fast path
---
frontend/.env.example | 1 +
frontend/app/chain-clients.ts | 20 +-
frontend/app/chain-config.ts | 5 +-
frontend/app/globals.css | 47 ++++
frontend/app/page-runtime-bindings.ts | 8 +-
frontend/app/page.tsx | 370 ++++++++++++++++++++++++--
frontend/app/somnia-session-keys.ts | 17 +-
7 files changed, 440 insertions(+), 28 deletions(-)
diff --git a/frontend/.env.example b/frontend/.env.example
index e1dc548..2be9b54 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -12,6 +12,7 @@ NEXT_PUBLIC_RISE_TESTNET_EXPLORER_URL=https://explorer.testnet.riselabs.xyz
# The public RISE deployment remains the default.
NEXT_PUBLIC_SOMNIA_SHANNON_DUNGEON_ADDRESS=0x07c5D071132ae95C3708031790b3feC740F4c292
NEXT_PUBLIC_SOMNIA_SHANNON_RPC_URL=https://dream-rpc.somnia.network/
+NEXT_PUBLIC_SOMNIA_SHANNON_WS_URL=wss://dream-rpc.somnia.network/ws
NEXT_PUBLIC_SOMNIA_SHANNON_EXPLORER_URL=https://shannon-explorer.somnia.network/
# Optional Somnia ERC-4337 Instant Play prototype. Keep disabled until the
diff --git a/frontend/app/chain-clients.ts b/frontend/app/chain-clients.ts
index a47f785..34827fe 100644
--- a/frontend/app/chain-clients.ts
+++ b/frontend/app/chain-clients.ts
@@ -10,12 +10,28 @@ export function createActivePublicClient() {
}
export function createActiveWebSocketClient() {
- if (!activeDeployment.wsUrl) {
+ if (
+ !activeDeployment.realtime.websocket ||
+ activeDeployment.realtime.shreds ||
+ !activeDeployment.wsUrl
+ ) {
return null;
}
return createPublicClient({
chain: activeDeployment.chain,
- transport: webSocket(activeDeployment.wsUrl),
+ cacheTime: 0,
+ transport: webSocket(activeDeployment.wsUrl, {
+ keepAlive: {
+ interval: 5_000,
+ },
+ reconnect: {
+ attempts: 100,
+ delay: 500,
+ },
+ retryCount: 5,
+ retryDelay: 100,
+ timeout: 15_000,
+ }),
});
}
diff --git a/frontend/app/chain-config.ts b/frontend/app/chain-config.ts
index 1ffece1..6bd9c8c 100644
--- a/frontend/app/chain-config.ts
+++ b/frontend/app/chain-config.ts
@@ -233,6 +233,9 @@ export const deployments = {
rpcUrl:
process.env.NEXT_PUBLIC_SOMNIA_SHANNON_RPC_URL ??
"https://dream-rpc.somnia.network/",
+ wsUrl:
+ process.env.NEXT_PUBLIC_SOMNIA_SHANNON_WS_URL ??
+ "wss://dream-rpc.somnia.network/ws",
explorerUrl:
process.env.NEXT_PUBLIC_SOMNIA_SHANNON_EXPLORER_URL ??
"https://shannon-explorer.somnia.network/",
@@ -246,7 +249,7 @@ export const deployments = {
: null,
},
realtime: {
- websocket: false,
+ websocket: true,
shreds: false,
},
randomness: {
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index c18e492..bb6f64c 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -25,6 +25,53 @@ body {
font-family: Arial, Helvetica, sans-serif;
}
+@keyframes combat-intent-attack {
+ 0%, 100% { transform: rotate(-7deg) translateY(0); }
+ 45% { transform: rotate(13deg) translateY(-3px); }
+}
+
+@keyframes combat-intent-storm {
+ 0%, 100% { filter: brightness(1); transform: scale(.96); }
+ 50% { filter: brightness(1.45); transform: scale(1.08); }
+}
+
+@keyframes combat-intent-potion {
+ 0%, 100% { transform: rotate(-4deg) translateY(1px); }
+ 50% { transform: rotate(6deg) translateY(-4px); }
+}
+
+@keyframes combat-intent-enter {
+ 0%, 100% { filter: brightness(.9); transform: scale(.96); }
+ 50% { filter: brightness(1.2); transform: scale(1.04); }
+}
+
+.combat-intent-attack {
+ animation: combat-intent-attack 1.15s ease-in-out infinite;
+}
+
+.combat-intent-storm {
+ animation: combat-intent-storm .9s ease-in-out infinite;
+}
+
+.combat-intent-potion {
+ animation: combat-intent-potion 1.2s ease-in-out infinite;
+}
+
+.combat-intent-enter,
+.combat-intent-random {
+ animation: combat-intent-enter 1.35s ease-in-out infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .combat-intent-attack,
+ .combat-intent-storm,
+ .combat-intent-potion,
+ .combat-intent-enter,
+ .combat-intent-random {
+ animation: none;
+ }
+}
+
.delveworn-practice-mode {
--delveworn-mode-accent: #22d3ee;
--delveworn-mode-accent-soft: rgba(34, 211, 238, .12);
diff --git a/frontend/app/page-runtime-bindings.ts b/frontend/app/page-runtime-bindings.ts
index ebe0e7c..e9833b1 100644
--- a/frontend/app/page-runtime-bindings.ts
+++ b/frontend/app/page-runtime-bindings.ts
@@ -1,5 +1,8 @@
import { activeDeployment } from "./chain-config";
-import { createActivePublicClient } from "./chain-clients";
+import {
+ createActivePublicClient,
+ createActiveWebSocketClient,
+} from "./chain-clients";
import { wagmiConfig } from "./wagmi-runtime";
import { ensureActiveChain } from "./wallet-network";
import {
@@ -71,6 +74,9 @@ export const ACTIVE_CHAIN_ID =
export const publicClient =
createActivePublicClient();
+export const eventWebSocketClient =
+ createActiveWebSocketClient();
+
export {
ACTIVE_ECOSYSTEM_NAME,
ACTIVE_NETWORK_LABEL,
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index f6ad4a1..677012a 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -67,6 +67,7 @@ import {
delayedRandomnessText,
delayedRandomnessTitle,
ensureActiveChain,
+ eventWebSocketClient,
isMetaMaskConnector,
isRiseWalletConnector,
publicClient,
@@ -86,6 +87,7 @@ import {
} from "./somnia-session-storage";
import {
type SomniaSessionHandle,
+ type SomniaSessionTransactionPhase,
} from "./somnia-session-keys";
/*
@@ -189,6 +191,10 @@ async function createConnectedWalletClient(
const realtimeClient =
createRiseShredsClient();
+const realtimeReadClient =
+ realtimeClient ??
+ eventWebSocketClient;
+
/*
============================================================
ABI
@@ -1920,7 +1926,7 @@ async function fetchPlayerState(
const reader =
source ===
"realtime"
- ? realtimeClient ?? publicClient
+ ? realtimeReadClient ?? publicClient
: publicClient;
let snapshot: unknown;
@@ -2067,16 +2073,28 @@ type VrfCacheEntry = {
player: Address;
requestId: bigint;
kind: number;
- source:
- "realtime" | "canonical";
+ source: VrfCompletionSource;
receivedAt: number;
};
+type VrfCompletionSource =
+ | "shreds"
+ | "websocket"
+ | "http"
+ | "state-polling";
+
+type ActionProgressPhase =
+ | "idle"
+ | SomniaSessionTransactionPhase
+ | "vrf"
+ | "syncing";
+
type LatencyBenchmarkSample = {
id: string;
action: string;
mode: string;
smartAccount: SmartAccountLatencyBreakdown | null;
+ vrfSource: VrfCompletionSource | null;
submissionMs: number;
vrfMs: number;
stateSyncMs: number;
@@ -2103,6 +2121,7 @@ type ActionTiming = {
smartAccount: SmartAccountLatencyBreakdown | null;
submissionConfirmedAt: number | null;
vrfEventReceivedAt: number | null;
+ vrfSource: VrfCompletionSource | null;
stateReadStartedAt: number | null;
stateReadCompletedAt: number | null;
sampleCompleted: boolean;
@@ -2996,6 +3015,14 @@ function DelvewornGame() {
RequestKind.None
);
+ const [
+ actionProgressPhase,
+ setActionProgressPhase,
+ ] =
+ useState
(
+ "idle"
+ );
+
const [
vrfDelayed,
setVrfDelayed,
@@ -3164,6 +3191,8 @@ function DelvewornGame() {
timing.mode,
smartAccount:
timing.smartAccount,
+ vrfSource:
+ timing.vrfSource,
submissionMs:
Math.max(
0,
@@ -3496,7 +3525,7 @@ function DelvewornGame() {
/*
==========================================================
- RISE SHREDS VRF EVENT CACHE
+ REALTIME VRF EVENT CACHE
==========================================================
*/
@@ -3514,7 +3543,7 @@ function DelvewornGame() {
/*
The lean RandomnessFulfilled completion signal is consumed through two independent paths:
- 1. Shreds / WebSocket: fastest path.
+ 1. Shreds or standard WebSocket: fastest path.
2. HTTP event polling: reliability fallback.
The HTTP watcher is intentionally always active. If RISE closes
@@ -3532,7 +3561,7 @@ function DelvewornGame() {
number,
source:
- "realtime" | "canonical"
+ VrfCompletionSource
) => {
const normalizedPlayer =
getAddress(
@@ -3575,8 +3604,8 @@ function DelvewornGame() {
};
/*
- The same completion can arrive first through Shreds and later
- through the HTTP watcher. Preserve the original receivedAt so a
+ The same completion can arrive first through a realtime feed and
+ later through the HTTP watcher. Preserve the original receivedAt so a
delayed duplicate from a previous attack can never masquerade as
the completion for a new attack of the same kind.
*/
@@ -3599,6 +3628,9 @@ function DelvewornGame() {
) {
actionTiming.vrfEventReceivedAt =
entry.receivedAt;
+
+ actionTiming.vrfSource =
+ entry.source;
}
timingLog(
@@ -3685,7 +3717,7 @@ function DelvewornGame() {
Number(
args.kind
),
- "realtime"
+ "shreds"
);
} catch (
error
@@ -3716,6 +3748,76 @@ function DelvewornGame() {
})
: () => {};
+ const unwatchWebSocketEvents =
+ eventWebSocketClient
+ ? eventWebSocketClient.watchContractEvent({
+ address:
+ DUNGEON_ADDRESS,
+
+ abi:
+ dungeonAbi,
+
+ eventName:
+ "RandomnessFulfilled",
+
+ args: {
+ player:
+ watchedAddress,
+ },
+
+ onLogs:
+ (
+ logs
+ ) => {
+ for (
+ const log
+ of logs
+ ) {
+ const args =
+ log.args as {
+ player?:
+ Address;
+
+ requestId?:
+ bigint;
+
+ kind?:
+ number;
+ };
+
+ if (
+ !args.player ||
+ args.requestId ===
+ undefined ||
+ args.kind ===
+ undefined
+ ) {
+ continue;
+ }
+
+ ingestVrfResult(
+ args.player,
+ args.requestId,
+ Number(
+ args.kind
+ ),
+ "websocket"
+ );
+ }
+ },
+
+ onError:
+ (
+ error
+ ) => {
+ console.warn(
+ `${ACTIVE_NETWORK_LABEL} WebSocket interrupted; HTTP event fallback remains active:`,
+ error
+ );
+ },
+ })
+ : () => {};
+
const unwatchHttpEvents =
publicClient.watchContractEvent({
address:
@@ -3774,7 +3876,7 @@ function DelvewornGame() {
Number(
args.kind
),
- "canonical"
+ "http"
);
}
},
@@ -3792,6 +3894,7 @@ function DelvewornGame() {
return () => {
unwatchShreds();
+ unwatchWebSocketEvents();
unwatchHttpEvents();
vrfResultsRef.current.clear();
};
@@ -3993,6 +4096,10 @@ function DelvewornGame() {
stage:
string
): PlayerState {
+ setActionProgressPhase(
+ "syncing"
+ );
+
setPlayer(
resolvedState
);
@@ -4283,7 +4390,10 @@ function DelvewornGame() {
const resolvedState =
await fetchPlayerState(
playerAddress,
- cached.source
+ cached.source ===
+ "http"
+ ? "canonical"
+ : "realtime"
);
timingLog(
@@ -4379,6 +4489,17 @@ function DelvewornGame() {
changed
)
) {
+ const actionTiming =
+ actionTimingRef.current;
+
+ if (
+ actionTiming &&
+ !actionTiming.vrfSource
+ ) {
+ actionTiming.vrfSource =
+ "state-polling";
+ }
+
const remainingDisplay =
MIN_VRF_DISPLAY_MS -
(
@@ -5537,6 +5658,10 @@ function DelvewornGame() {
"connector.getProvider start"
);
+ setActionProgressPhase(
+ "preparing"
+ );
+
const provider =
(
await connector
@@ -5664,6 +5789,10 @@ function DelvewornGame() {
"wallet_sendPreparedCalls start"
);
+ setActionProgressPhase(
+ "submitting"
+ );
+
const result =
await provider.request({
method:
@@ -5704,6 +5833,10 @@ function DelvewornGame() {
);
}
+ setActionProgressPhase(
+ "inclusion"
+ );
+
if (!waitForStatus) {
timingLog(
`bundle accepted (${bundleId})`
@@ -5934,6 +6067,10 @@ function DelvewornGame() {
args,
} as never);
+ setActionProgressPhase(
+ "submitting"
+ );
+
const hash =
await walletClient
.sendTransaction({
@@ -5946,6 +6083,10 @@ function DelvewornGame() {
data,
});
+ setActionProgressPhase(
+ "inclusion"
+ );
+
const receipt =
await waitForReceipt(
hash
@@ -6010,7 +6151,14 @@ function DelvewornGame() {
const result =
await sendSomniaSessionTransaction(
somniaSessionHandle.record,
- data
+ data,
+ (
+ phase
+ ) => {
+ setActionProgressPhase(
+ phase
+ );
+ }
);
if (actionTimingRef.current) {
@@ -6965,6 +7113,8 @@ function DelvewornGame() {
null,
vrfEventReceivedAt:
null,
+ vrfSource:
+ null,
stateReadStartedAt:
null,
stateReadCompletedAt:
@@ -7085,6 +7235,10 @@ function DelvewornGame() {
setRollingKind(
expectedRequestKind
);
+
+ setActionProgressPhase(
+ "preparing"
+ );
}
const sessionResult =
@@ -7111,6 +7265,15 @@ function DelvewornGame() {
transactionSubmitted =
true;
+ if (
+ expectedRequestKind !==
+ RequestKind.None
+ ) {
+ setActionProgressPhase(
+ "vrf"
+ );
+ }
+
let resolved:
PlayerState | null =
null;
@@ -7652,6 +7815,10 @@ function DelvewornGame() {
RequestKind.None
);
+ setActionProgressPhase(
+ "idle"
+ );
+
setPendingAction(
null
);
@@ -8393,6 +8560,9 @@ function DelvewornGame() {
let rollingText =
"The dungeon is making questionable decisions...";
+ let rollingIconClass =
+ "combat-intent-random";
+
if (
requestKind ===
RequestKind.Monster
@@ -8402,6 +8572,9 @@ function DelvewornGame() {
? "👑"
: "🚪";
+ rollingIconClass =
+ "combat-intent-enter";
+
rollingTitle =
isBossRoom
? "MANAGEMENT INCOMING"
@@ -8420,6 +8593,9 @@ function DelvewornGame() {
rollingIcon =
"⚔️";
+ rollingIconClass =
+ "combat-intent-attack";
+
rollingTitle =
"ROLLING ATTACK";
@@ -8434,6 +8610,9 @@ function DelvewornGame() {
rollingIcon =
"⚡";
+ rollingIconClass =
+ "combat-intent-storm";
+
rollingTitle =
"UNLEASHING STORM";
@@ -8448,6 +8627,9 @@ function DelvewornGame() {
rollingIcon =
"🧪";
+ rollingIconClass =
+ "combat-intent-potion";
+
rollingTitle =
"DRINKING SUSPICIOUS LIQUID";
@@ -8455,12 +8637,109 @@ function DelvewornGame() {
"Healing is easy. Retaliation is less convenient.";
}
+ if (
+ actionProgressPhase ===
+ "preparing"
+ ) {
+ rollingLabel =
+ "ACTION · 1 OF 5";
+
+ rollingTitle =
+ "PREPARING MOVE";
+
+ rollingText =
+ "Building the restricted session action and estimating its cost...";
+ }
+
+ if (
+ actionProgressPhase ===
+ "sponsoring"
+ ) {
+ rollingLabel =
+ "ACTION · 2 OF 5";
+
+ rollingTitle =
+ "SPONSORING MOVE";
+
+ rollingText =
+ "Thirdweb is approving sponsored gas for this zero-value action...";
+ }
+
+ if (
+ actionProgressPhase ===
+ "submitting"
+ ) {
+ rollingLabel =
+ "ACTION · 3 OF 5";
+
+ rollingTitle =
+ "SENDING ONCHAIN";
+
+ rollingText =
+ `The signed move is being sent to ${ACTIVE_ECOSYSTEM_NAME}...`;
+ }
+
+ if (
+ actionProgressPhase ===
+ "inclusion"
+ ) {
+ rollingLabel =
+ "ACTION · 4 OF 5";
+
+ rollingTitle =
+ "WAITING FOR INCLUSION";
+
+ rollingText =
+ `The bundler accepted the move. Waiting for ${ACTIVE_ECOSYSTEM_NAME} to include it...`;
+ }
+
+ if (
+ actionProgressPhase ===
+ "vrf"
+ ) {
+ rollingLabel =
+ "VRF · 5 OF 5";
+ }
+
+ const actionProgressOrder:
+ ActionProgressPhase[] =
+ hasSomniaSession
+ ? [
+ "preparing",
+ "sponsoring",
+ "submitting",
+ "inclusion",
+ "vrf",
+ ]
+ : [
+ "preparing",
+ "submitting",
+ "inclusion",
+ "vrf",
+ ];
+
+ const actionProgressIndex =
+ actionProgressOrder.indexOf(
+ actionProgressPhase
+ );
+
+ if (
+ actionProgressIndex >=
+ 0
+ ) {
+ rollingLabel =
+ `${actionProgressPhase === "vrf" ? "VRF" : "ACTION"} · ${actionProgressIndex + 1} OF ${actionProgressOrder.length}`;
+ }
+
if (
canonicalSyncing
) {
rollingIcon =
"⛓️";
+ rollingIconClass =
+ "combat-intent-random";
+
rollingTitle =
"FINALIZING ACTION";
@@ -8475,6 +8754,9 @@ function DelvewornGame() {
rollingIcon =
"⏳";
+ rollingIconClass =
+ "combat-intent-random";
+
rollingLabel =
ACTIVE_NETWORK_LABEL.toUpperCase();
@@ -8607,7 +8889,18 @@ function DelvewornGame() {
: "—"}
/>
{sample.mode} · {sample.action}: {smart
? `prep ${(smart.preparationMs / 1_000).toFixed(2)}s (estimate ${(smart.gasEstimationMs / 1_000).toFixed(2)}s) · paymaster ${(smart.paymasterMs / 1_000).toFixed(2)}s · bundler ${(smart.bundlerSubmissionMs / 1_000).toFixed(2)}s · inclusion ${(smart.inclusionWaitMs / 1_000).toFixed(2)}s (${smart.receiptPollCount} polls @ ${smart.receiptPollingIntervalMs}ms) · `
- : `submit ${(sample.submissionMs / 1_000).toFixed(2)}s · `}VRF {(sample.vrfMs / 1_000).toFixed(2)}s · state {(sample.stateSyncMs / 1_000).toFixed(2)}s · total {(sample.totalMs / 1_000).toFixed(2)}s
+ : `submit ${(sample.submissionMs / 1_000).toFixed(2)}s · `}VRF {(sample.vrfMs / 1_000).toFixed(2)}s ({sample.vrfSource ?? "unknown"}) · state {(sample.stateSyncMs / 1_000).toFixed(2)}s · total {(sample.totalMs / 1_000).toFixed(2)}s
);
}
@@ -9148,7 +9441,7 @@ function DelvewornGame() {
-
+
{rollingIcon}
@@ -9164,15 +9457,46 @@ function DelvewornGame() {
{rollingText}
-
-
-
-
-
-
-
+ {!vrfDelayed &&
+ !canonicalSyncing &&
+ actionProgressIndex >= 0 && (
+
+ {actionProgressOrder.map(
+ (
+ phase,
+ index
+ ) => (
+
+ )
+ )}
+
+ )}
-
+ {(
+ vrfDelayed ||
+ canonicalSyncing ||
+ actionProgressIndex < 0
+ ) && (
+
+
+
+
+
+ )}
{supportsRandomnessRetry() &&
vrfRetryAvailable &&
diff --git a/frontend/app/somnia-session-keys.ts b/frontend/app/somnia-session-keys.ts
index ea137d9..22450ad 100644
--- a/frontend/app/somnia-session-keys.ts
+++ b/frontend/app/somnia-session-keys.ts
@@ -52,6 +52,12 @@ export type SomniaSessionTransactionBenchmark = {
totalMs: number;
};
+export type SomniaSessionTransactionPhase =
+ | "preparing"
+ | "sponsoring"
+ | "submitting"
+ | "inclusion";
+
function benchmarkNowMs() {
return globalThis.performance?.now() ?? Date.now();
}
@@ -267,7 +273,8 @@ export async function createSomniaSession(
export async function sendSomniaSessionTransaction(
record: SomniaSessionRecord,
- data: Hex
+ data: Hex,
+ onPhase?: (phase: SomniaSessionTransactionPhase) => void
) {
const client = thirdwebClient();
const sessionSigner = privateKeyToAccount({
@@ -297,6 +304,8 @@ export async function sendSomniaSessionTransaction(
let userOpSubmissionStartedAt: number | null = null;
let userOpSubmittedAt: number | null = null;
+ onPhase?.("preparing");
+
const benchmarkFetch: typeof globalThis.fetch = async (
input,
init
@@ -309,6 +318,11 @@ export async function sendSomniaSessionTransaction(
userOpSubmissionStartedAt === null
) {
userOpSubmissionStartedAt = requestStartedAt;
+ onPhase?.("submitting");
+ }
+
+ if (method === "pm_sponsorUserOperation") {
+ onPhase?.("sponsoring");
}
if (method === "eth_getUserOperationReceipt") {
@@ -356,6 +370,7 @@ export async function sendSomniaSessionTransaction(
userOp: signedUserOp,
options: bundlerOptions,
});
+ onPhase?.("inclusion");
const receipt = await waitForUserOpReceipt({
...bundlerOptions,
userOpHash,
From e944e15ca4feca0f25e8bac491fb74caf17a6fd8 Mon Sep 17 00:00:00 2001
From: CryptoMickle <318943357+CryptoMickle@users.noreply.github.com>
Date: Wed, 2 Sep 2026 10:19:58 +0200
Subject: [PATCH 6/7] fix: allow Somnia VRF websocket events
---
frontend/app/page.tsx | 20 ++++++++++----------
frontend/next.config.ts | 1 +
2 files changed, 11 insertions(+), 10 deletions(-)
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 677012a..6a017c7 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -191,10 +191,6 @@ async function createConnectedWalletClient(
const realtimeClient =
createRiseShredsClient();
-const realtimeReadClient =
- realtimeClient ??
- eventWebSocketClient;
-
/*
============================================================
ABI
@@ -1926,7 +1922,7 @@ async function fetchPlayerState(
const reader =
source ===
"realtime"
- ? realtimeReadClient ?? publicClient
+ ? realtimeClient ?? publicClient
: publicClient;
let snapshot: unknown;
@@ -3173,14 +3169,15 @@ function DelvewornGame() {
timing.stateReadCompletedAt ??
completedAt;
- const stateReadStartedAt =
- timing.stateReadStartedAt ??
- vrfEventReceivedAt;
-
const stateReadCompletedAt =
timing.stateReadCompletedAt ??
completedAt;
+ const stateSyncStartedAt =
+ timing.vrfEventReceivedAt ??
+ timing.stateReadStartedAt ??
+ stateReadCompletedAt;
+
const sample:
LatencyBenchmarkSample = {
id:
@@ -3209,7 +3206,7 @@ function DelvewornGame() {
Math.max(
0,
stateReadCompletedAt -
- stateReadStartedAt
+ stateSyncStartedAt
),
totalMs:
Math.max(
@@ -3765,6 +3762,9 @@ function DelvewornGame() {
watchedAddress,
},
+ poll:
+ false,
+
onLogs:
(
logs
diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index 8ecf714..075fe28 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -12,6 +12,7 @@ const contentSecurityPolicy = [
[
"connect-src 'self'",
"https://dream-rpc.somnia.network",
+ "wss://dream-rpc.somnia.network",
"https://testnet.riselabs.xyz",
"wss://testnet.riselabs.xyz",
"https://*.thirdweb.com",
From 3d3fca6bed4bfd2bd84f2cf8b1bd417807753bbe Mon Sep 17 00:00:00 2001
From: CryptoMickle <318943357+CryptoMickle@users.noreply.github.com>
Date: Wed, 2 Sep 2026 11:22:09 +0200
Subject: [PATCH 7/7] feat: position Somnia as a verified run
---
README.md | 4 +-
frontend/app/page.tsx | 188 ++++++++++++++++------------
frontend/app/practice-mode-link.tsx | 8 +-
frontend/app/somnia-session-keys.ts | 2 +-
4 files changed, 117 insertions(+), 85 deletions(-)
diff --git a/README.md b/README.md
index 0317cf0..22980f2 100644
--- a/README.md
+++ b/README.md
@@ -93,7 +93,7 @@ The frontend uses `frontendSnapshotV3()`, `claimRelic(bool)` and `equipOwnedReli
| Environment | Status | Scope |
| --- | --- | --- |
| RISE Testnet | Public beta | Current wallet-connected deployment and frontend integration. |
-| Somnia Shannon Testnet | Verified opt-in deployment | Delveworn [`0x07c5…c292`](https://shannon-explorer.somnia.network/address/0x07c5D071132ae95C3708031790b3feC740F4c292) uses a native VRF adapter and Somnia's coordinator-funded Reactivity/drand flow. A live monster request completed with `callbackSuccess=true`; the public frontend remains on RISE unless explicitly configured otherwise. Standard MetaMask play is live, while Thirdweb ERC-4337 Instant Play is implemented behind a disabled-by-default feature flag pending sponsored-gas configuration and live QA. |
+| Somnia Shannon Testnet | Verified opt-in deployment | Delveworn [`0x07c5…c292`](https://shannon-explorer.somnia.network/address/0x07c5D071132ae95C3708031790b3feC740F4c292) uses a native VRF adapter and Somnia's coordinator-funded Reactivity/drand flow. A live monster request completed with `callbackSuccess=true`; the public frontend remains on RISE unless explicitly configured otherwise. Standard MetaMask play is live, while Thirdweb ERC-4337 Popup-free Play is available behind a deployment feature flag. It removes repeated wallet approvals but still waits for bundling, block inclusion and verified randomness. |
| Local Anvil | Development only | Deterministic contract, relic, balance and request/callback testing through `DevRandomnessAdapter`. |
| Chainlink VRF v2.5 adapter | Implemented and test-covered | Adapter support exists, but no public deployment is presented as production-ready. |
| Other EVM networks | Architecture target | The core is designed for adapter-based deployments; these networks are not yet advertised as supported public deployments. |
@@ -248,7 +248,7 @@ NEXT_PUBLIC_DEPLOYMENT=somniaShannon
NEXT_PUBLIC_SOMNIA_SHANNON_DUNGEON_ADDRESS=0x07c5D071132ae95C3708031790b3feC740F4c292
```
-The optional Somnia Instant Play prototype additionally requires:
+The optional Somnia Popup-free Play session additionally requires:
```text
NEXT_PUBLIC_SOMNIA_SESSION_KEYS_ENABLED=true
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 6a017c7..ba82dbc 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -3255,7 +3255,7 @@ function DelvewornGame() {
setSomniaSessionHandle(null);
setPlayer(null);
setWalletMessage(
- "The Instant Play session expired. Approve a new temporary session to continue."
+ "The popup-free session expired. Approve a new temporary session to continue."
);
}, remainingMs);
@@ -4901,7 +4901,7 @@ function DelvewornGame() {
);
setWalletMessage(
- "The previous Instant Play session expired or was revoked. Approve a new temporary session to continue."
+ "The previous popup-free session expired or was revoked. Approve a new temporary session to continue."
);
}
}
@@ -4928,7 +4928,7 @@ function DelvewornGame() {
!supportsThirdwebSessionKeys()
) {
throw new Error(
- "Somnia Instant Play is not enabled for this deployment."
+ "Somnia popup-free play is not enabled for this deployment."
);
}
@@ -5100,7 +5100,7 @@ function DelvewornGame() {
);
setWalletMessage(
- "Could not enable Somnia Instant Play. The MetaMask approval, smart-account deployment or sponsored session request failed."
+ "Could not enable Somnia popup-free play. The MetaMask approval, smart-account deployment or sponsored session request failed."
);
} finally {
setSomniaSessionCreating(
@@ -6128,7 +6128,7 @@ function DelvewornGame() {
!hasSomniaSession
) {
throw new Error(
- "Somnia Instant Play session is not active."
+ "Somnia popup-free session is not active."
);
}
@@ -7099,8 +7099,10 @@ function DelvewornGame() {
? "RISE Instant Play"
: somniaSessionMode &&
hasSomniaSession
- ? "Somnia Instant Play"
- : "MetaMask Standard Play",
+ ? "Somnia Popup-free Play"
+ : ACTIVE_ECOSYSTEM_NAME === "Somnia"
+ ? "Somnia Verified Run"
+ : "MetaMask Standard Play",
expectedRequestKind:
RequestKind.None,
startedAt:
@@ -8053,20 +8055,26 @@ function DelvewornGame() {
VIEW CONTRACT · {DUNGEON_ADDRESS.slice(0, 6)}…{DUNGEON_ADDRESS.slice(-4)} ↗
@@ -8089,7 +8097,7 @@ function DelvewornGame() {
>
{activeInstantPlayProvider() === "rise-wallet"
? "⚡ RISE WALLET · INSTANT PLAY"
- : "⚡ METAMASK · INSTANT PLAY"}
+ : "🔑 METAMASK · POPUP-FREE PLAY"}
)}
connectWallet("metamask")}
className={`${supportsInstantPlay() ? "mt-3 " : ""}w-full rounded-xl border border-zinc-700 bg-zinc-800 py-4 text-lg font-black text-white transition hover:bg-zinc-700`}
>
- 🦊 METAMASK · STANDARD PLAY
+ {ACTIVE_ECOSYSTEM_NAME === "Somnia"
+ ? "🦊 METAMASK · CONFIRM EACH ACTION"
+ : "🦊 METAMASK · STANDARD PLAY"}
@@ -8168,7 +8178,9 @@ function DelvewornGame() {
- Instant Play
+ {supportsThirdwebSessionKeys()
+ ? "Popup-free Play"
+ : "Instant Play"}
@@ -8217,7 +8229,9 @@ function DelvewornGame() {
>
{grantPermissions.isPending || somniaSessionCreating
? "CREATING SESSION..."
- : "🔑 ENABLE INSTANT PLAY"}
+ : supportsThirdwebSessionKeys()
+ ? "🔑 ENABLE POPUP-FREE PLAY"
+ : "🔑 ENABLE INSTANT PLAY"}
{walletMessage && (
@@ -8792,7 +8806,9 @@ function DelvewornGame() {
- 🔑 INSTANT PLAY ACTIVE
+ {hasSomniaSession
+ ? "🔑 POPUP-FREE VERIFIED PLAY ACTIVE"
+ : "🔑 INSTANT PLAY ACTIVE"}
- 🦊 METAMASK · STANDARD PLAY
+ {ACTIVE_ECOSYSTEM_NAME === "Somnia"
+ ? "🦊 VERIFIED RUN · CONFIRM EACH ACTION"
+ : "🦊 METAMASK · STANDARD PLAY"}
-
-
-
- BUNDLER + VRF BENCHMARK
-
-
- Instant Play split follows Thirdweb's ERC-4337 flow. Prepare includes local work and gas estimation; inclusion uses fast receipt polling after bundler submission.
+
+
+ TECHNICAL DETAILS · BUNDLER + VRF
+
+ {latencySamples.length > 0
+ ? `${(latencySamples[0].totalMs / 1_000).toFixed(2)}s latest`
+ : "no samples"}
+
+
+
+
+
+
+ Popup-free play still uses Thirdweb's ERC-4337 bundler, block inclusion and Somnia's verifiable-randomness callback. These timings are diagnostic and do not change the proof model.
-
- {latencySamples.length >
- 0 && (
-
- setLatencySamples(
- []
- )
- }
- className="text-[9px] text-zinc-500 underline transition hover:text-zinc-300"
- >
- clear
-
- )}
-
+ {latencySamples.length >
+ 0 && (
+
+ setLatencySamples(
+ []
+ )
+ }
+ className="shrink-0 text-[9px] text-zinc-500 underline transition hover:text-zinc-300"
+ >
+ clear
+
+ )}
+
- {latencySamples.length ===
- 0 ? (
-
- Run Attack, Storm, Potion or enter a room to record the first sample.
-
- ) : (
- <>
-
+ {latencySamples.length ===
+ 0 ? (
+
+ Run Attack, Storm, Potion or enter a room to record the first sample.
+
+ ) : (
+ <>
+
-
-
-
-
- {latencySamples[0].mode} · {latencySamples[0].action} · show history
-
-
- {latencySamples.map(
- (sample) => {
- const smart =
- sample.smartAccount;
-
- return (
-
- {sample.mode} · {sample.action}: {smart
- ? `prep ${(smart.preparationMs / 1_000).toFixed(2)}s (estimate ${(smart.gasEstimationMs / 1_000).toFixed(2)}s) · paymaster ${(smart.paymasterMs / 1_000).toFixed(2)}s · bundler ${(smart.bundlerSubmissionMs / 1_000).toFixed(2)}s · inclusion ${(smart.inclusionWaitMs / 1_000).toFixed(2)}s (${smart.receiptPollCount} polls @ ${smart.receiptPollingIntervalMs}ms) · `
- : `submit ${(sample.submissionMs / 1_000).toFixed(2)}s · `}VRF {(sample.vrfMs / 1_000).toFixed(2)}s ({sample.vrfSource ?? "unknown"}) · state {(sample.stateSyncMs / 1_000).toFixed(2)}s · total {(sample.totalMs / 1_000).toFixed(2)}s
-
- );
- }
- )}
-
- >
- )}
-
+
+
+
+ {latencySamples[0].mode} · {latencySamples[0].action} · show history
+
+
+ {latencySamples.map(
+ (sample) => {
+ const smart =
+ sample.smartAccount;
+
+ return (
+
+ {sample.mode} · {sample.action}: {smart
+ ? `prep ${(smart.preparationMs / 1_000).toFixed(2)}s (estimate ${(smart.gasEstimationMs / 1_000).toFixed(2)}s) · paymaster ${(smart.paymasterMs / 1_000).toFixed(2)}s · bundler ${(smart.bundlerSubmissionMs / 1_000).toFixed(2)}s · inclusion ${(smart.inclusionWaitMs / 1_000).toFixed(2)}s (${smart.receiptPollCount} polls @ ${smart.receiptPollingIntervalMs}ms) · `
+ : `submit ${(sample.submissionMs / 1_000).toFixed(2)}s · `}VRF {(sample.vrfMs / 1_000).toFixed(2)}s ({sample.vrfSource ?? "unknown"}) · state {(sample.stateSyncMs / 1_000).toFixed(2)}s · total {(sample.totalMs / 1_000).toFixed(2)}s
+
+ );
+ }
+ )}
+
+
+ >
+ )}
+
+
)}
{/* ===================================================
@@ -8992,8 +9016,10 @@ function DelvewornGame() {
{!player.hasStarted ? (
VIEW CONTRACT · {DUNGEON_ADDRESS.slice(0, 6)}…{DUNGEON_ADDRESS.slice(-4)} ↗
@@ -9012,7 +9038,9 @@ function DelvewornGame() {
disabled={busy}
className="delveworn-primary-cta mt-7 w-full rounded-xl py-4 text-lg font-black transition disabled:opacity-50"
>
- ⚔️ START ONCHAIN RUN
+ {ACTIVE_ECOSYSTEM_NAME === "Somnia"
+ ? "⛓️ START VERIFIED RUN"
+ : "⚔️ START ONCHAIN RUN"}
Gameplay actions use your selected wallet mode.
@@ -10359,7 +10387,9 @@ function DelvewornGame() {
V8.6.9b · {runtimeFooterLabel()} · {supportsInstantPlay()
- ? `${activeInstantPlayProvider() === "rise-wallet" ? "RISE" : "Somnia"} Instant Play · `
+ ? activeInstantPlayProvider() === "rise-wallet"
+ ? "RISE Instant Play · "
+ : "Somnia Popup-free Play · "
: ""}MetaMask Standard Play
diff --git a/frontend/app/practice-mode-link.tsx b/frontend/app/practice-mode-link.tsx
index 696baa6..2048b0a 100644
--- a/frontend/app/practice-mode-link.tsx
+++ b/frontend/app/practice-mode-link.tsx
@@ -6,14 +6,16 @@ import { usePathname } from "next/navigation";
export default function PracticeModeLink() {
const pathname = usePathname();
- if (pathname === "/practice") return null;
+ const isPractice = pathname === "/practice";
return (
- Practice · No VRF
+ {isPractice
+ ? "Verified Run · Onchain"
+ : "Play now · Instant local"}
);
}
diff --git a/frontend/app/somnia-session-keys.ts b/frontend/app/somnia-session-keys.ts
index 22450ad..71a0eda 100644
--- a/frontend/app/somnia-session-keys.ts
+++ b/frontend/app/somnia-session-keys.ts
@@ -105,7 +105,7 @@ function thirdwebClient() {
if (!clientId) {
throw new Error(
- "Somnia Instant Play requires NEXT_PUBLIC_THIRDWEB_CLIENT_ID."
+ "Somnia Popup-free Play requires NEXT_PUBLIC_THIRDWEB_CLIENT_ID."
);
}