diff --git a/README.md b/README.md index 9bbefc6..fb023a7 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ Current adapter implementations include: - `LegacyVRFAdapter.sol` - `ChainlinkV25DirectFundingAdapter.sol` +- `SomniaNativeVRFAdapter.sol` — Somnia Reactivity-native, drand-mixed VRF - `DevRandomnessAdapter.sol` — deterministic and **DEV/TEST ONLY** See `docs/CHAIN_AGNOSTIC_ARCHITECTURE.md` for the architecture rules. @@ -92,6 +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 | Adapter and deployment path test-covered | Native verifiable VRF requests use Somnia's coordinator-funded Reactivity/drand flow. No public Delveworn deployment is advertised until its address and live fulfillment are verified. | | 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. | @@ -189,6 +191,22 @@ bash scripts/dev-autofulfill.sh The development adapter preserves the two-transaction request/callback lifecycle. It is operator-controlled and must never be used as a production or competitive randomness source. +## Somnia Shannon deployment + +`SomniaNativeVRFAdapter` translates Delveworn's provider-neutral request and callback interface to Somnia's native `requestRandomWords` / `rawFulfillRandomWords` ABI. Requests always set `useVerifiableEntropy: true`. The default Shannon coordinator is `0x0834459256bbb8d2efee23dc6c3f1722266182dd`. + +Deploy the adapter and a fresh Delveworn core with the native coordinator: + +```bash +forge script script/DeploySomniaShannon.s.sol:DeploySomniaShannon \ + --rpc-url https://dream-rpc.somnia.network \ + --gas-limit 150000000 \ + --broadcast \ + --private-key "$SOMNIA_DEPLOYER_PRIVATE_KEY" +``` + +The high transaction gas limit accommodates Shannon's deployment gas accounting; unused gas is not charged. The script deliberately defaults to Somnia's maximum `2_500_000` callback gas and minimum `16`-block commit delay. Override them only within Somnia's documented bounds with `SOMNIA_VRF_CALLBACK_GAS_LIMIT` and `SOMNIA_VRF_COMMIT_DELAY_BLOCKS`. The native coordinator pays entropy-delivery costs; the adapter requires no subscription or prefunding. + ## Deployment Vercel is connected to this repository with `frontend` as its Root Directory. Changes merged to `main` trigger the production deployment. Configure deployment variables in Vercel rather than committing `.env.local`. diff --git a/docs/CHAIN_AGNOSTIC_ARCHITECTURE.md b/docs/CHAIN_AGNOSTIC_ARCHITECTURE.md index 0db9274..ed60e1a 100644 --- a/docs/CHAIN_AGNOSTIC_ARCHITECTURE.md +++ b/docs/CHAIN_AGNOSTIC_ARCHITECTURE.md @@ -23,6 +23,7 @@ Current adapters include: - `LegacyVRFAdapter` for compatibility with the original VRF-style request/callback interface. - `ChainlinkV25DirectFundingAdapter` for Chainlink VRF v2.5 wrapper direct funding with the chain's native token. +- `SomniaNativeVRFAdapter` for Somnia's Reactivity-native, drand-mixed request and callback interface. ## Deployment strategy diff --git a/script/DeploySomniaShannon.s.sol b/script/DeploySomniaShannon.s.sol index e21b73d..e07f876 100644 --- a/script/DeploySomniaShannon.s.sol +++ b/script/DeploySomniaShannon.s.sol @@ -2,30 +2,46 @@ pragma solidity ^0.8.24; import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; import {Delveworn} from "../src/Delveworn.sol"; -import {ChainlinkV25DirectFundingAdapter} from "../src/adapters/ChainlinkV25DirectFundingAdapter.sol"; +import {SomniaNativeVRFAdapter} from "../src/adapters/SomniaNativeVRFAdapter.sol"; contract DeploySomniaShannon is Script { - address internal constant DEFAULT_VRF_WRAPPER = 0x763cC914d5CA79B04dC4787aC14CcAd780a16BD2; - - function run() external returns (ChainlinkV25DirectFundingAdapter adapter, Delveworn dungeon) { - address wrapper = vm.envOr("SOMNIA_SHANNON_VRF_WRAPPER", DEFAULT_VRF_WRAPPER); - uint32 callbackGasLimit = uint32(vm.envOr("SOMNIA_VRF_CALLBACK_GAS_LIMIT", uint256(500_000))); - uint16 requestConfirmations = uint16(vm.envOr("SOMNIA_VRF_REQUEST_CONFIRMATIONS", uint256(3))); - uint256 prefundWei = vm.envOr("SOMNIA_VRF_PREFUND_WEI", uint256(0)); + address internal constant DEFAULT_VRF_COORDINATOR = 0x0834459256bBB8D2EFEe23dc6C3F1722266182dD; + uint256 internal constant MAX_CALLBACK_GAS_LIMIT = 2_500_000; + uint256 internal constant MIN_COMMIT_DELAY_BLOCKS = 16; + uint256 internal constant MAX_COMMIT_DELAY_BLOCKS = 200; + + function run() external returns (SomniaNativeVRFAdapter adapter, Delveworn dungeon) { + address coordinator = vm.envOr("SOMNIA_SHANNON_VRF_COORDINATOR", DEFAULT_VRF_COORDINATOR); + uint256 callbackGasLimitValue = vm.envOr("SOMNIA_VRF_CALLBACK_GAS_LIMIT", uint256(2_500_000)); + uint256 commitDelayBlocksValue = vm.envOr("SOMNIA_VRF_COMMIT_DELAY_BLOCKS", uint256(16)); + + require(coordinator != address(0), "Invalid Somnia VRF coordinator"); + require( + callbackGasLimitValue > 0 && callbackGasLimitValue <= MAX_CALLBACK_GAS_LIMIT, "Invalid callback gas limit" + ); + require( + commitDelayBlocksValue >= MIN_COMMIT_DELAY_BLOCKS && commitDelayBlocksValue <= MAX_COMMIT_DELAY_BLOCKS, + "Invalid commit delay" + ); + + uint32 callbackGasLimit = uint32(callbackGasLimitValue); + uint16 commitDelayBlocks = uint16(commitDelayBlocksValue); vm.startBroadcast(); - adapter = new ChainlinkV25DirectFundingAdapter(wrapper, callbackGasLimit, requestConfirmations); + adapter = new SomniaNativeVRFAdapter(coordinator, callbackGasLimit, commitDelayBlocks); dungeon = new Delveworn(address(adapter)); adapter.setConsumer(address(dungeon)); - if (prefundWei > 0) { - (bool success,) = address(adapter).call{value: prefundWei}(""); - require(success, "Adapter prefund failed"); - } - vm.stopBroadcast(); + + console2.log("Somnia native VRF coordinator:", coordinator); + console2.log("Somnia native VRF adapter:", address(adapter)); + console2.log("Delveworn:", address(dungeon)); + console2.log("Callback gas limit:", callbackGasLimit); + console2.log("Commit delay blocks:", commitDelayBlocks); } } diff --git a/src/adapters/SomniaNativeVRFAdapter.sol b/src/adapters/SomniaNativeVRFAdapter.sol new file mode 100644 index 0000000..a2c8145 --- /dev/null +++ b/src/adapters/SomniaNativeVRFAdapter.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IRandomnessAdapter, IRandomnessConsumer} from "../interfaces/IRandomnessAdapter.sol"; +import {ISomniaVRFCoordinator} from "../interfaces/ISomniaVRFCoordinator.sol"; + +/// @notice Provider adapter for Somnia's Reactivity-native, drand-mixed VRF. +/// @dev Requests always use verifiable entropy so the same adapter configuration is safe on +/// Shannon testnet and Somnia mainnet. The provider-neutral seed is intentionally ignored. +contract SomniaNativeVRFAdapter is IRandomnessAdapter { + uint32 public constant MAX_CALLBACK_GAS_LIMIT = 2_500_000; + uint16 public constant MIN_COMMIT_DELAY_BLOCKS = 16; + uint16 public constant MAX_COMMIT_DELAY_BLOCKS = 200; + uint32 public constant MAX_NUM_WORDS = 500; + + address public immutable owner; + ISomniaVRFCoordinator public immutable coordinator; + uint32 public immutable callbackGasLimit; + uint16 public immutable commitDelayBlocks; + + address public consumer; + + mapping(uint256 => bool) public pendingRequests; + + event ConsumerSet(address indexed consumer); + event AdapterRandomnessRequested(uint256 indexed requestId, address indexed consumer, uint32 numberCount); + event AdapterRandomnessFulfilled(uint256 indexed requestId, address indexed consumer); + + error OnlyOwner(); + error OnlyConsumer(); + error OnlyCoordinator(); + error ConsumerAlreadySet(); + error InvalidAddress(); + error InvalidConfig(); + error InvalidNumberCount(); + error UnknownRequest(); + + constructor(address coordinatorAddress, uint32 callbackGasLimit_, uint16 commitDelayBlocks_) { + if (coordinatorAddress == address(0) || coordinatorAddress.code.length == 0) revert InvalidAddress(); + if (callbackGasLimit_ == 0 || callbackGasLimit_ > MAX_CALLBACK_GAS_LIMIT) revert InvalidConfig(); + if (commitDelayBlocks_ < MIN_COMMIT_DELAY_BLOCKS || commitDelayBlocks_ > MAX_COMMIT_DELAY_BLOCKS) { + revert InvalidConfig(); + } + + owner = msg.sender; + coordinator = ISomniaVRFCoordinator(coordinatorAddress); + callbackGasLimit = callbackGasLimit_; + commitDelayBlocks = commitDelayBlocks_; + } + + function setConsumer(address consumerAddress) external { + if (msg.sender != owner) revert OnlyOwner(); + if (consumer != address(0)) revert ConsumerAlreadySet(); + if (consumerAddress == address(0) || consumerAddress.code.length == 0) revert InvalidAddress(); + + consumer = consumerAddress; + emit ConsumerSet(consumerAddress); + } + + function requestRandomNumbers(uint32 numNumbers, uint256) external override returns (uint256 requestId) { + if (msg.sender != consumer || consumer == address(0)) revert OnlyConsumer(); + if (numNumbers == 0 || numNumbers > MAX_NUM_WORDS) revert InvalidNumberCount(); + + requestId = coordinator.requestRandomWords( + ISomniaVRFCoordinator.RandomWordsRequest({ + callbackGasLimit: callbackGasLimit, + commitDelayBlocks: commitDelayBlocks, + numWords: numNumbers, + useVerifiableEntropy: true + }) + ); + + if (requestId == 0 || pendingRequests[requestId]) revert UnknownRequest(); + pendingRequests[requestId] = true; + + emit AdapterRandomnessRequested(requestId, msg.sender, numNumbers); + } + + /// @notice Somnia native VRF callback entrypoint. + function rawFulfillRandomWords(uint256 requestId, uint256[] calldata randomWords) external { + if (msg.sender != address(coordinator)) revert OnlyCoordinator(); + if (!pendingRequests[requestId]) revert UnknownRequest(); + + delete pendingRequests[requestId]; + + IRandomnessConsumer(consumer).rawFulfillRandomNumbers(requestId, randomWords); + + emit AdapterRandomnessFulfilled(requestId, consumer); + } +} diff --git a/src/interfaces/ISomniaVRFCoordinator.sol b/src/interfaces/ISomniaVRFCoordinator.sol new file mode 100644 index 0000000..a9101c3 --- /dev/null +++ b/src/interfaces/ISomniaVRFCoordinator.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @notice Minimal interface for Somnia's Reactivity-native VRF coordinator. +interface ISomniaVRFCoordinator { + struct RandomWordsRequest { + uint32 callbackGasLimit; + uint16 commitDelayBlocks; + uint32 numWords; + bool useVerifiableEntropy; + } + + function requestRandomWords(RandomWordsRequest calldata request) external returns (uint256 requestId); +} diff --git a/test/SomniaNativeVRFAdapter.t.sol b/test/SomniaNativeVRFAdapter.t.sol new file mode 100644 index 0000000..a627248 --- /dev/null +++ b/test/SomniaNativeVRFAdapter.t.sol @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {Delveworn} from "../src/Delveworn.sol"; +import {SomniaNativeVRFAdapter} from "../src/adapters/SomniaNativeVRFAdapter.sol"; +import {ISomniaVRFCoordinator} from "../src/interfaces/ISomniaVRFCoordinator.sol"; + +contract MockSomniaVRFCoordinator is ISomniaVRFCoordinator { + uint256 public nextRequestId = 1; + + address public lastRequester; + RandomWordsRequest public lastRequest; + + mapping(uint256 => RandomWordsRequest) internal requests; + + function requestRandomWords(RandomWordsRequest calldata request) external returns (uint256 requestId) { + requestId = nextRequestId++; + lastRequester = msg.sender; + lastRequest = request; + requests[requestId] = request; + } + + function fulfill(address adapter, uint256 requestId, uint256[] memory randomWords) external { + SomniaNativeVRFAdapter(adapter).rawFulfillRandomWords(requestId, randomWords); + } + + function fulfillWithConfiguredGas(address adapter, uint256 requestId, uint256[] memory randomWords) + external + returns (bool success, uint256 gasUsed, bytes memory returnData) + { + uint256 gasBefore = gasleft(); + (success, returnData) = adapter.call{gas: requests[requestId].callbackGasLimit}( + abi.encodeCall(SomniaNativeVRFAdapter.rawFulfillRandomWords, (requestId, randomWords)) + ); + gasUsed = gasBefore - gasleft(); + } +} + +contract SomniaNativeVRFAdapterTest is Test { + MockSomniaVRFCoordinator internal coordinator; + SomniaNativeVRFAdapter internal adapter; + + uint256 internal fulfilledRequestId; + uint256[] internal fulfilledWords; + + function setUp() public { + coordinator = new MockSomniaVRFCoordinator(); + adapter = new SomniaNativeVRFAdapter(address(coordinator), 2_500_000, 16); + adapter.setConsumer(address(this)); + } + + function rawFulfillRandomNumbers(uint256 requestId, uint256[] memory randomWords) external { + require(msg.sender == address(adapter), "only adapter"); + fulfilledRequestId = requestId; + fulfilledWords = randomWords; + } + + function testRequestUsesVerifiableSomniaConfigAndTracksPendingRequest() public { + uint256 requestId = adapter.requestRandomNumbers(5, 12345); + + assertEq(requestId, 1); + assertTrue(adapter.pendingRequests(requestId)); + assertEq(coordinator.lastRequester(), address(adapter)); + + (uint32 callbackGasLimit, uint16 commitDelayBlocks, uint32 numWords, bool useVerifiableEntropy) = + coordinator.lastRequest(); + + assertEq(callbackGasLimit, 2_500_000); + assertEq(commitDelayBlocks, 16); + assertEq(numWords, 5); + assertTrue(useVerifiableEntropy); + } + + function testCoordinatorFulfillmentForwardsToConsumer() public { + uint256 requestId = adapter.requestRandomNumbers(2, 0); + + uint256[] memory words = new uint256[](2); + words[0] = 111; + words[1] = 222; + + coordinator.fulfill(address(adapter), requestId, words); + + assertFalse(adapter.pendingRequests(requestId)); + assertEq(fulfilledRequestId, requestId); + assertEq(fulfilledWords.length, 2); + assertEq(fulfilledWords[0], 111); + assertEq(fulfilledWords[1], 222); + } + + function testOnlyConfiguredConsumerCanRequest() public { + vm.prank(address(0xBEEF)); + vm.expectRevert(SomniaNativeVRFAdapter.OnlyConsumer.selector); + adapter.requestRandomNumbers(1, 0); + } + + function testRejectsInvalidNumberCounts() public { + vm.expectRevert(SomniaNativeVRFAdapter.InvalidNumberCount.selector); + adapter.requestRandomNumbers(0, 0); + + vm.expectRevert(SomniaNativeVRFAdapter.InvalidNumberCount.selector); + adapter.requestRandomNumbers(501, 0); + } + + function testOnlyCoordinatorCanFulfill() public { + uint256 requestId = adapter.requestRandomNumbers(1, 0); + uint256[] memory words = _one(1); + + vm.expectRevert(SomniaNativeVRFAdapter.OnlyCoordinator.selector); + adapter.rawFulfillRandomWords(requestId, words); + } + + function testConsumerCanOnlyBeSetOnce() public { + vm.expectRevert(SomniaNativeVRFAdapter.ConsumerAlreadySet.selector); + adapter.setConsumer(address(0xBEEF)); + } + + function testOnlyOwnerCanSetConsumer() public { + SomniaNativeVRFAdapter unconfigured = new SomniaNativeVRFAdapter(address(coordinator), 2_500_000, 16); + + vm.prank(address(0xBEEF)); + vm.expectRevert(SomniaNativeVRFAdapter.OnlyOwner.selector); + unconfigured.setConsumer(address(this)); + } + + function testRejectsZeroAddresses() public { + vm.expectRevert(SomniaNativeVRFAdapter.InvalidAddress.selector); + new SomniaNativeVRFAdapter(address(0), 2_500_000, 16); + + vm.expectRevert(SomniaNativeVRFAdapter.InvalidAddress.selector); + new SomniaNativeVRFAdapter(address(0xBEEF), 2_500_000, 16); + + SomniaNativeVRFAdapter unconfigured = new SomniaNativeVRFAdapter(address(coordinator), 2_500_000, 16); + vm.expectRevert(SomniaNativeVRFAdapter.InvalidAddress.selector); + unconfigured.setConsumer(address(0)); + + vm.expectRevert(SomniaNativeVRFAdapter.InvalidAddress.selector); + unconfigured.setConsumer(address(0xBEEF)); + } + + function testConstructorRejectsProviderValuesOutsideNativeBounds() public { + vm.expectRevert(SomniaNativeVRFAdapter.InvalidConfig.selector); + new SomniaNativeVRFAdapter(address(coordinator), 0, 16); + + vm.expectRevert(SomniaNativeVRFAdapter.InvalidConfig.selector); + new SomniaNativeVRFAdapter(address(coordinator), 2_500_001, 16); + + vm.expectRevert(SomniaNativeVRFAdapter.InvalidConfig.selector); + new SomniaNativeVRFAdapter(address(coordinator), 2_500_000, 15); + + vm.expectRevert(SomniaNativeVRFAdapter.InvalidConfig.selector); + new SomniaNativeVRFAdapter(address(coordinator), 2_500_000, 201); + } + + function _one(uint256 value) internal pure returns (uint256[] memory words) { + words = new uint256[](1); + words[0] = value; + } +} + +contract SomniaNativeVRFDelvewornIntegrationTest is Test { + uint32 internal constant CALLBACK_GAS_LIMIT = 2_500_000; + + address internal player = address(0xA11CE); + + MockSomniaVRFCoordinator internal coordinator; + SomniaNativeVRFAdapter internal adapter; + Delveworn internal dungeon; + + function setUp() public { + coordinator = new MockSomniaVRFCoordinator(); + adapter = new SomniaNativeVRFAdapter(address(coordinator), CALLBACK_GAS_LIMIT, 16); + dungeon = new Delveworn(address(adapter)); + adapter.setConsumer(address(dungeon)); + } + + function testMonsterCallbackCompletesThroughNativeGasLimit() public { + vm.prank(player); + dungeon.startGame(); + + uint256 requestId = dungeon.pendingRequestId(player); + (bool success, uint256 gasUsed,) = coordinator.fulfillWithConfiguredGas(address(adapter), requestId, _one(42)); + + assertTrue(success); + assertLt(gasUsed, CALLBACK_GAS_LIMIT); + assertEq(dungeon.pendingRequestId(player), 0); + assertGt(dungeon.getPlayer(player).monsterHp, 0); + } + + function testBossRelicCallbackCompletesThroughNativeGasLimit() public { + _startWithMonster(); + + for (uint256 room = 1; room <= 9; room++) { + _killCurrentMonsterWithArmorLoot(); + + if (room < 9) { + vm.prank(player); + dungeon.enterNextRoom(); + _fulfillCurrent(_one(0)); + } + } + + assertTrue(dungeon.campAvailable(player)); + + vm.prank(player); + dungeon.enterNextRoom(); + _fulfillCurrent(_one(0)); + + while (dungeon.getPlayer(player).monsterHp > 24) { + _attackAndFulfill(_five(4, 0, 0, 95, 99_999)); + } + + vm.prank(player); + dungeon.attack(); + + uint256 finalRequestId = dungeon.pendingRequestId(player); + (bool success, uint256 gasUsed,) = + coordinator.fulfillWithConfiguredGas(address(adapter), finalRequestId, _five(4, 0, 0, 95, 99_999)); + + assertTrue(success); + assertLt(gasUsed, CALLBACK_GAS_LIMIT); + assertEq(dungeon.pendingRequestId(player), 0); + assertEq(dungeon.getPlayer(player).roomsCleared, 10); + assertTrue(dungeon.relicOfferAvailable(player)); + } + + function testTimedOutNativeRequestCanBeRetriedAndFulfilled() public { + vm.prank(player); + dungeon.startGame(); + + uint256 abandonedRequestId = dungeon.pendingRequestId(player); + uint256 requestedAt = dungeon.pendingRequestTimestamp(player); + vm.warp(requestedAt + dungeon.VRF_TIMEOUT()); + + vm.prank(player); + uint256 retryRequestId = dungeon.retryRandomness(); + + assertGt(retryRequestId, abandonedRequestId); + assertEq(dungeon.pendingRequestId(player), retryRequestId); + assertTrue(adapter.pendingRequests(abandonedRequestId)); + assertTrue(adapter.pendingRequests(retryRequestId)); + + (bool success, uint256 gasUsed,) = + coordinator.fulfillWithConfiguredGas(address(adapter), retryRequestId, _one(42)); + + assertTrue(success); + assertLt(gasUsed, CALLBACK_GAS_LIMIT); + assertFalse(adapter.pendingRequests(retryRequestId)); + assertEq(dungeon.pendingRequestId(player), 0); + assertGt(dungeon.getPlayer(player).monsterHp, 0); + + (bool staleSuccess,,) = coordinator.fulfillWithConfiguredGas(address(adapter), abandonedRequestId, _one(99)); + assertFalse(staleSuccess); + assertTrue(adapter.pendingRequests(abandonedRequestId)); + } + + function _startWithMonster() internal { + vm.prank(player); + dungeon.startGame(); + _fulfillCurrent(_one(0)); + } + + function _killCurrentMonsterWithArmorLoot() internal { + while (dungeon.getPlayer(player).monsterHp > 0) { + _attackAndFulfill(_five(2, 0, 0, 95, 0)); + } + } + + function _attackAndFulfill(uint256[] memory words) internal { + vm.prank(player); + dungeon.attack(); + _fulfillCurrent(words); + } + + function _fulfillCurrent(uint256[] memory words) internal { + uint256 requestId = dungeon.pendingRequestId(player); + assertGt(requestId, 0); + coordinator.fulfill(address(adapter), requestId, words); + } + + function _one(uint256 value) internal pure returns (uint256[] memory words) { + words = new uint256[](1); + words[0] = value; + } + + function _five(uint256 a, uint256 b, uint256 c, uint256 d, uint256 e) + internal + pure + returns (uint256[] memory words) + { + words = new uint256[](5); + words[0] = a; + words[1] = b; + words[2] = c; + words[3] = d; + words[4] = e; + } +}