From b8e28cf4ff8fd50eea99975d298d476788db7598 Mon Sep 17 00:00:00 2001 From: CryptoMickle <318943357+CryptoMickle@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:42:55 +0200 Subject: [PATCH 1/3] feat: add feature-flagged Somnia session keys --- .github/workflows/frontend.yml | 20 +- README.md | 16 +- frontend/.env.example | 5 + frontend/README.md | 21 +- frontend/app/chain-config.ts | 30 +- frontend/app/page-runtime-bindings.ts | 2 + frontend/app/page.tsx | 579 +- frontend/app/session-provider-policy.ts | 7 + frontend/app/somnia-session-keys.ts | 250 + frontend/app/somnia-session-storage.ts | 115 + frontend/package-lock.json | 18394 ++++++++++++++++------ frontend/package.json | 5 + 12 files changed, 14621 insertions(+), 4823 deletions(-) create mode 100644 frontend/app/somnia-session-keys.ts create mode 100644 frontend/app/somnia-session-storage.ts diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index c113fd0..7094f13 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -15,14 +15,24 @@ on: jobs: check: - name: Next.js checks (${{ matrix.deployment }}) + name: Next.js checks (${{ matrix.name }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: - deployment: - - riseTestnet - - somniaShannon + include: + - name: riseTestnet + deployment: riseTestnet + session_keys: "false" + thirdweb_client_id: "" + - name: somniaShannon-standard + deployment: somniaShannon + session_keys: "false" + thirdweb_client_id: "" + - name: somniaShannon-session-keys + deployment: somniaShannon + session_keys: "true" + thirdweb_client_id: "ci-public-client-id" permissions: contents: read defaults: @@ -52,4 +62,6 @@ jobs: NEXT_PUBLIC_DEPLOYMENT: ${{ matrix.deployment }} NEXT_PUBLIC_RISE_TESTNET_DUNGEON_ADDRESS: "0x0000000000000000000000000000000000000001" NEXT_PUBLIC_SOMNIA_SHANNON_DUNGEON_ADDRESS: "0x07c5D071132ae95C3708031790b3feC740F4c292" + NEXT_PUBLIC_SOMNIA_SESSION_KEYS_ENABLED: ${{ matrix.session_keys }} + NEXT_PUBLIC_THIRDWEB_CLIENT_ID: ${{ matrix.thirdweb_client_id }} run: npm run build diff --git a/README.md b/README.md index e1b3ca1..0317cf0 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. Somnia supports ERC-4337-style smart accounts and session keys, but this Delveworn configuration currently exposes only its tested MetaMask transaction path. | +| 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. | | 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,6 +248,20 @@ NEXT_PUBLIC_DEPLOYMENT=somniaShannon NEXT_PUBLIC_SOMNIA_SHANNON_DUNGEON_ADDRESS=0x07c5D071132ae95C3708031790b3feC740F4c292 ``` +The optional Somnia Instant Play prototype additionally requires: + +```text +NEXT_PUBLIC_SOMNIA_SESSION_KEYS_ENABLED=true +NEXT_PUBLIC_THIRDWEB_CLIENT_ID= +``` + +Keep the feature flag disabled until the Thirdweb client is restricted to the +intended domain and has a Shannon sponsored-gas policy. MetaMask remains the +owner/admin wallet. The temporary session key expires after eight hours and is +limited onchain to zero-value calls against the configured Delveworn contract. +Because the ERC-4337 smart account has its own address, it has separate player +state from the owner's MetaMask EOA. + Do not change the production deployment selector merely by merging Somnia support; the RISE URL is referenced by existing grant applications and remains the default. Changes must pass the path-filtered contract and frontend workflows before they are merged to `main`. diff --git a/frontend/.env.example b/frontend/.env.example index 4f0bdf2..e1dc548 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -14,6 +14,11 @@ NEXT_PUBLIC_SOMNIA_SHANNON_DUNGEON_ADDRESS=0x07c5D071132ae95C3708031790b3feC740F NEXT_PUBLIC_SOMNIA_SHANNON_RPC_URL=https://dream-rpc.somnia.network/ NEXT_PUBLIC_SOMNIA_SHANNON_EXPLORER_URL=https://shannon-explorer.somnia.network/ +# Optional Somnia ERC-4337 Instant Play prototype. Keep disabled until the +# Thirdweb client has an approved domain and sponsored-gas policy for Shannon. +NEXT_PUBLIC_SOMNIA_SESSION_KEYS_ENABLED=false +NEXT_PUBLIC_THIRDWEB_CLIENT_ID= + # Legacy fallbacks retained for backwards compatibility. # Prefer the deployment-specific variables above for new setups. # NEXT_PUBLIC_DUNGEON_ADDRESS=0x0000000000000000000000000000000000000000 diff --git a/frontend/README.md b/frontend/README.md index c1ed216..ef010e6 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,6 +1,6 @@ # Delveworn -Delveworn is a fully onchain dungeon crawler. The public frontend remains on RISE Testnet and supports RISE Wallet session keys for fast, popup-free gameplay, plus MetaMask standard transactions. A separately selectable Somnia Shannon configuration uses MetaMask and Somnia Native VRF without changing the public RISE experience. +Delveworn is a fully onchain dungeon crawler. The public frontend remains on RISE Testnet and supports RISE Wallet session keys for fast, popup-free gameplay, plus MetaMask standard transactions. A separately selectable Somnia Shannon configuration uses MetaMask and Somnia Native VRF without changing the public RISE experience. Somnia ERC-4337 Instant Play is implemented behind an explicit, disabled-by-default feature flag. ## Status @@ -13,6 +13,7 @@ Public testnet beta. The game is deployed through Vercel from the `main` branch - TypeScript - RISE Wallet - MetaMask +- Thirdweb ERC-4337 smart accounts - viem + wagmi - shreds - TanStack Query @@ -38,6 +39,22 @@ NEXT_PUBLIC_DEPLOYMENT=somniaShannon NEXT_PUBLIC_SOMNIA_SHANNON_DUNGEON_ADDRESS=0x07c5D071132ae95C3708031790b3feC740F4c292 ``` +Standard MetaMask play remains the Somnia default. To test the separate +ERC-4337 Instant Play path, create a public Thirdweb client for Shannon, +configure its allowed development domain and sponsored-gas policy, then add: + +```bash +NEXT_PUBLIC_SOMNIA_SESSION_KEYS_ENABLED=true +NEXT_PUBLIC_THIRDWEB_CLIENT_ID=your_public_client_id +``` + +Instant Play uses MetaMask as the smart account owner. It creates a local +8-hour session key whose onchain permissions allow only zero-value calls to the +configured Delveworn contract. The key is stored in browser local storage and +is never sent to Delveworn or the repository. The ERC-4337 smart account is a +different onchain player address from the MetaMask EOA, so existing EOA game +progress does not transfer between the two modes. + `NEXT_PUBLIC_DUNGEON_ADDRESS` remains supported as a legacy fallback. Start the development server: @@ -79,4 +96,4 @@ The repository includes `scripts/vrf-latency-monitor.mjs` for measuring request/ ## Network -The public frontend targets RISE Testnet. Somnia Shannon is available as an opt-in build configuration using chain ID `50312`, the official Dream RPC and Somnia Native VRF. Somnia supports ERC-4337-style smart accounts and session keys at the network/tooling layer, but Delveworn's Somnia configuration currently uses MetaMask standard transactions until a specific account-abstraction provider and smart-account flow are integrated and tested. Product naming remains chain-independent; contract addresses and network configuration must be selected per deployment before any mainnet release. +The public frontend targets RISE Testnet. Somnia Shannon is available as an opt-in build configuration using chain ID `50312`, the official Dream RPC and Somnia Native VRF. Its Thirdweb ERC-4337 smart-account/session-key path is feature-flagged and must be explicitly configured and tested before public activation; MetaMask standard transactions remain available independently. Product naming remains chain-independent; contract addresses and network configuration must be selected per deployment before any mainnet release. diff --git a/frontend/app/chain-config.ts b/frontend/app/chain-config.ts index c5d693a..1ffece1 100644 --- a/frontend/app/chain-config.ts +++ b/frontend/app/chain-config.ts @@ -8,6 +8,7 @@ import { riseTestnet } from "viem/chains"; export type InstantPlayProvider = | "rise-wallet" + | "thirdweb-erc4337" | null; export type RandomnessProvider = @@ -114,6 +115,15 @@ function validateDeploymentConfig(config: DeploymentConfig) { ); } + if ( + config.wallet.instantPlayProvider === "thirdweb-erc4337" && + !config.wallet.metaMask + ) { + throw new Error( + `Deployment ${config.key} selects Thirdweb ERC-4337 for Instant Play while MetaMask is disabled.` + ); + } + if (!config.randomness.providerLabel.trim()) { throw new Error( `Deployment ${config.key} must provide a randomness provider label.` @@ -149,6 +159,18 @@ const somniaShannon = defineChain({ testnet: true, }); +const somniaSessionKeysEnabled = + process.env.NEXT_PUBLIC_SOMNIA_SESSION_KEYS_ENABLED === "true"; + +if ( + somniaSessionKeysEnabled && + !process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID +) { + throw new Error( + "NEXT_PUBLIC_THIRDWEB_CLIENT_ID is required when Somnia session keys are enabled." + ); +} + export const deployments = { riseTestnet: defineDeployment({ key: "rise-testnet", @@ -217,9 +239,11 @@ export const deployments = { wallet: { riseWallet: false, metaMask: true, - sessionKeys: false, - gaslessTransactions: false, - instantPlayProvider: null, + sessionKeys: somniaSessionKeysEnabled, + gaslessTransactions: somniaSessionKeysEnabled, + instantPlayProvider: somniaSessionKeysEnabled + ? "thirdweb-erc4337" + : null, }, realtime: { websocket: false, diff --git a/frontend/app/page-runtime-bindings.ts b/frontend/app/page-runtime-bindings.ts index 10378d0..ebe0e7c 100644 --- a/frontend/app/page-runtime-bindings.ts +++ b/frontend/app/page-runtime-bindings.ts @@ -12,6 +12,7 @@ import { isRiseWalletConnector, sessionStorageKey, supportsInstantPlay, + supportsThirdwebSessionKeys, } from "./session-provider-policy"; import { activeRandomnessProvider, @@ -90,6 +91,7 @@ export { runtimeFooterLabel, sessionStorageKey, supportsInstantPlay, + supportsThirdwebSessionKeys, supportsRandomnessRetry, wagmiConfig, }; diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 553c6f9..6d41536 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -61,6 +61,7 @@ import { TESTNET_POLLING_MS, VRF_CANONICAL_FALLBACK_MS, VRF_DELAYED_NOTICE_MS, + activeInstantPlayProvider, activeRandomnessProviderLabel, connectedNetworkLabel, delayedRandomnessText, @@ -73,8 +74,19 @@ import { sessionStorageKey, supportsInstantPlay, supportsRandomnessRetry, + supportsThirdwebSessionKeys, wagmiConfig, } from "./page-runtime-bindings"; +import { + clearSomniaSessionRecord, + readSomniaSessionRecord, + setSomniaSessionMode, + wantsSomniaSessionMode, + writeSomniaSessionRecord, +} from "./somnia-session-storage"; +import { + type SomniaSessionHandle, +} from "./somnia-session-keys"; /* ============================================================ @@ -2911,6 +2923,21 @@ function DelvewornGame() { ] = useState(false); + const [ + somniaSessionMode, + setSomniaSessionModeEnabled, + ] = useState(false); + + const [ + somniaSessionHandle, + setSomniaSessionHandle, + ] = useState(null); + + const [ + somniaSessionCreating, + setSomniaSessionCreating, + ] = useState(false); + const [ loading, setLoading, @@ -3052,6 +3079,26 @@ function DelvewornGame() { connectedAddress, ]); + useEffect(() => { + if (!somniaSessionHandle) { + return; + } + + const remainingMs = Math.max( + 0, + somniaSessionHandle.record.expiresAt - Date.now() + ); + const timeout = window.setTimeout(() => { + setSomniaSessionHandle(null); + setPlayer(null); + setWalletMessage( + "The Instant Play session expired. Approve a new temporary session to continue." + ); + }, remainingMs); + + return () => window.clearTimeout(timeout); + }, [somniaSessionHandle]); + const permissionList = ( permissionsQuery.data ?? @@ -3108,6 +3155,12 @@ function DelvewornGame() { ) ); + const hasSomniaSession = + Boolean( + somniaSessionMode && + somniaSessionHandle + ); + const isRiseWallet = isRiseWalletConnector( connector @@ -3124,7 +3177,13 @@ function DelvewornGame() { isRiseWallet && hasSession ) || - isMetaMask + ( + isMetaMask && + ( + !somniaSessionMode || + hasSomniaSession + ) + ) ); function loadStoredSession( @@ -4457,10 +4516,105 @@ function DelvewornGame() { } } + async function restoreSomniaInstantPlay( + ownerAddress: Address + ) { + setSomniaSessionModeEnabled( + true + ); + + setSomniaSessionMode( + ownerAddress, + true + ); + + const record = + readSomniaSessionRecord( + ownerAddress + ); + + if (!record) { + setSomniaSessionHandle( + null + ); + + setConnectedAddress( + ownerAddress + ); + + setPlayer( + null + ); + + setLoading( + false + ); + + return; + } + + try { + const { + restoreSomniaSession, + } = await import( + "./somnia-session-keys" + ); + + const handle = + await restoreSomniaSession( + record + ); + + setSomniaSessionHandle( + handle + ); + + setConnectedAddress( + handle.record.smartAccountAddress + ); + + await loadPlayer( + handle.record.smartAccountAddress + ); + } catch ( + error + ) { + console.warn( + "Stored Somnia session could not be restored:", + error + ); + + clearSomniaSessionRecord( + ownerAddress + ); + + setSomniaSessionHandle( + null + ); + + setConnectedAddress( + ownerAddress + ); + + setPlayer( + null + ); + + setLoading( + false + ); + + setWalletMessage( + "The previous Instant Play session expired or was revoked. Approve a new temporary session to continue." + ); + } + } + async function connectWallet( wallet: "rise" | - "metamask" + "metamask" | + "somnia-session" ) { const label = wallet === @@ -4468,7 +4622,24 @@ function DelvewornGame() { ? "RISE Wallet" : "MetaMask"; + const wantsSomniaSession = + wallet === + "somnia-session"; + try { + if ( + wantsSomniaSession && + !supportsThirdwebSessionKeys() + ) { + throw new Error( + "Somnia Instant Play is not enabled for this deployment." + ); + } + + setSomniaSessionModeEnabled( + wantsSomniaSession + ); + setWalletMessage( "" ); @@ -4500,6 +4671,45 @@ function DelvewornGame() { ); } + if ( + isConnected && + wagmiAddress && + connector?.id === + connectorToUse.id + ) { + const ownerAddress = + getAddress( + wagmiAddress + ); + + if ( + wantsSomniaSession + ) { + await restoreSomniaInstantPlay( + ownerAddress + ); + } else { + setSomniaSessionMode( + ownerAddress, + false + ); + + setSomniaSessionHandle( + null + ); + + setConnectedAddress( + ownerAddress + ); + + await loadPlayer( + ownerAddress + ); + } + + return; + } + await connectMutation .mutateAsync({ connector: @@ -4524,7 +4734,94 @@ function DelvewornGame() { } } + async function createSomniaInstantPlaySession() { + if ( + !wagmiAddress || + !connector || + !isMetaMask + ) { + setWalletMessage( + "Connect MetaMask first." + ); + + return; + } + + const ownerAddress = + getAddress( + wagmiAddress + ); + + try { + setSomniaSessionCreating( + true + ); + + setWalletMessage( + "" + ); + + const { + createSomniaSession, + } = await import( + "./somnia-session-keys" + ); + + const handle = + await createSomniaSession( + ownerAddress + ); + + writeSomniaSessionRecord( + handle.record + ); + + setSomniaSessionMode( + ownerAddress, + true + ); + + setSomniaSessionHandle( + handle + ); + + setConnectedAddress( + handle.record.smartAccountAddress + ); + + setLoading( + true + ); + + await loadPlayer( + handle.record.smartAccountAddress + ); + } catch ( + error + ) { + console.error( + error + ); + + setWalletMessage( + "Could not enable Somnia Instant Play. The MetaMask approval, smart-account deployment or sponsored session request failed." + ); + } finally { + setSomniaSessionCreating( + false + ); + } + } + async function createSession() { + if ( + supportsThirdwebSessionKeys() && + somniaSessionMode + ) { + await createSomniaInstantPlaySession(); + return; + } + if ( !connectedAddress || !connector @@ -4690,6 +4987,25 @@ function DelvewornGame() { false ); + if ( + wagmiAddress + ) { + setSomniaSessionMode( + getAddress( + wagmiAddress + ), + false + ); + } + + setSomniaSessionModeEnabled( + false + ); + + setSomniaSessionHandle( + null + ); + setPlayer( null ); @@ -4729,6 +5045,69 @@ function DelvewornGame() { } try { + if ( + hasSomniaSession && + somniaSessionHandle && + wagmiAddress && + connector + ) { + const ownerAddress = + getAddress( + wagmiAddress + ); + + const { + revokeSomniaSession, + } = await import( + "./somnia-session-keys" + ); + + const result = + await revokeSomniaSession( + somniaSessionHandle.record + ); + + const receipt = + await waitForReceipt( + result.transactionHash + ); + + if ( + receipt.status !== + "success" + ) { + throw new Error( + "Session revocation reverted." + ); + } + + clearSomniaSessionRecord( + ownerAddress + ); + + setSomniaSessionHandle( + null + ); + + setSomniaSessionModeEnabled( + false + ); + + setConnectedAddress( + ownerAddress + ); + + setLoading( + true + ); + + await loadPlayer( + ownerAddress + ); + + return; + } + if ( activePermission ?.id @@ -5416,6 +5795,71 @@ function DelvewornGame() { }; } + async function sendDungeonSomniaSessionCall( + functionName: + SessionAction, + + args: + readonly unknown[] = [] + ): Promise< + SessionSendResult + > { + if ( + !somniaSessionHandle || + !hasSomniaSession + ) { + throw new Error( + "Somnia Instant Play session is not active." + ); + } + + const data = + encodeFunctionData({ + abi: + dungeonAbi, + + functionName, + + args, + } as never); + + const { + sendSomniaSessionTransaction, + } = await import( + "./somnia-session-keys" + ); + + const result = + await sendSomniaSessionTransaction( + somniaSessionHandle.account, + data + ); + + const receipt = + await waitForReceipt( + result.transactionHash + ); + + if ( + receipt.status !== + "success" + ) { + throw new Error( + "Somnia session transaction reverted." + ); + } + + return { + hash: + result.transactionHash, + logs: + receipt.logs as unknown as + SessionReceiptLog[], + bundleId: + "", + }; + } + async function sendDungeonActionCall( functionName: SessionAction, @@ -5449,6 +5893,15 @@ function DelvewornGame() { if ( isMetaMask ) { + if ( + somniaSessionMode + ) { + return sendDungeonSomniaSessionCall( + functionName, + args + ); + } + return sendDungeonStandardCall( functionName, args @@ -6975,6 +7428,14 @@ function DelvewornGame() { null ); + setSomniaSessionHandle( + null + ); + + setSomniaSessionModeEnabled( + false + ); + setLoading( false ); @@ -7022,10 +7483,52 @@ function DelvewornGame() { wagmiAddress ); + const shouldRestoreSomniaSession = + isMetaMaskConnector( + connector + ) && + supportsThirdwebSessionKeys() && + ( + somniaSessionMode || + wantsSomniaSessionMode( + address + ) + ); + + if ( + shouldRestoreSomniaSession + ) { + setConnectedAddress( + address + ); + + setPlayer( + null + ); + + setLoading( + true + ); + + void restoreSomniaInstantPlay( + address + ); + + return; + } + setConnectedAddress( address ); + setSomniaSessionModeEnabled( + false + ); + + setSomniaSessionHandle( + null + ); + if ( connector.id === RISE_WALLET_CONNECTOR_ID @@ -7100,6 +7603,17 @@ function DelvewornGame() { ========================================================== */ + const needsInstantPlayApproval = + ( + isRiseWallet && + !hasSession + ) || + ( + isMetaMask && + somniaSessionMode && + !hasSomniaSession + ); + if ( loading ) { @@ -7111,8 +7625,11 @@ function DelvewornGame() { } if ( - !connectedAddress || - !player + ( + !connectedAddress || + !player + ) && + !needsInstantPlayApproval ) { return (
@@ -7129,7 +7646,9 @@ function DelvewornGame() { eyebrow={walletChoiceOpen ? "CHOOSE YOUR ENTRY" : "FULLY ONCHAIN EXPEDITION"} description={walletChoiceOpen ? supportsInstantPlay() - ? "Choose Instant Play with RISE Wallet, or Standard Play with MetaMask." + ? activeInstantPlayProvider() === "rise-wallet" + ? "Choose Instant Play with RISE Wallet, or Standard Play with MetaMask." + : "Choose popup-free Instant Play through a temporary Somnia smart-account session, or Standard Play with MetaMask." : `Connect MetaMask for standard wallet-signed play on ${ACTIVE_NETWORK_LABEL}.` : "Enter the live testnet game. Actions, randomness, progress and rewards are verifiable onchain."} proofFooter={ @@ -7143,10 +7662,18 @@ function DelvewornGame() { {supportsInstantPlay() && ( )} @@ -7299,6 +7833,17 @@ function DelvewornGame() { ); } + if ( + !connectedAddress || + !player + ) { + return ( +
+ Reconnecting to {ACTIVE_NETWORK_LABEL}... +
+ ); + } + /* ========================================================== DERIVED STATE @@ -7712,7 +8257,7 @@ function DelvewornGame() { subtitle={subtitle} meta={<>ONCHAIN SESSION · {connectedAddress.slice(0, 6)}…{connectedAddress.slice(-4)}} > - {isRiseWallet ? ( + {isRiseWallet || hasSomniaSession ? (
@@ -9137,7 +9682,9 @@ function DelvewornGame() {