Contracts
The system was designed before deployment, not after. Here is the contract set, the one decision that matters most — where your NFT lives while staked — and the launch order. Nothing is deployed yet.
Your NFT never goes into escrow
Most staking moves the NFT to a contract. That is the most dangerous pattern available: you stop being the owner, and any bug means the collection is locked forever. Rain Pools does the opposite — the pool stays in your wallet, and while it is locked it simply cannot be transferred.
| Escrow | Lock in place | |
|---|---|---|
| Owner while staked | the contract | you |
| What OpenSea shows | held by contract | held by you |
| If the contract is exploited | NFT can be stolen | worst case: a stuck lock |
| Needs approval on the collection | yes, full | no |
setApprovalForAll removes an entire class of attacks. Even if the staking contract were compromised, it has no permission to move your NFT — it physically cannot. The worst outcome is that a pool is temporarily unsellable, and it unlocks on expiry.The set
| Contract | Role | Key properties |
|---|---|---|
| RainPoolsNFT | ERC-721, 3,333 items | IPFS metadata, ERC-2981 royalties, lock support, ownership renounced after reveal |
| PoolStaking | tracks open pools | 8h lock, blocks transfers, never holds an NFT, cannot transfer one |
| WeatherOracle | publishes weather | commit-reveal, bounded durations, public fallback |
| $RPOOL | reward token | ERC-20, hard cap, mint restricted to the vault |
| RewardVault | accrual and claims | pull model: the contract never pushes, you claim |
Interfaces
The frontend is already written against these shapes. When the contracts exist, the site only needs the addresses.
interface IPoolStaking {
event PoolOpened(uint256 indexed tokenId, address indexed owner, uint64 until);
event PoolClosed(uint256 indexed tokenId, address indexed owner);
// Owner-only. No NFT is received, no approval is requested.
function openPool(uint256 tokenId) external;
function openPoolBatch(uint256[] calldata tokenIds) external;
// Only after the lock expires.
function closePool(uint256 tokenId) external;
function stakedAt(uint256 tokenId) external view returns (uint64);
function lockedUntil(uint256 tokenId) external view returns (uint64);
function isOpen(uint256 tokenId) external view returns (bool);
}
interface IWeatherOracle {
enum Kind { Rain, Drought }
struct Weather {
uint64 index;
Kind kind;
uint8 severity; // 0..3
uint64 startsAt;
uint64 endsAt;
bytes32 seedHash; // published up front
bytes32 seed; // revealed at the end
}
function current() external view returns (Weather memory);
function commit(bytes32 seedHash, uint64 endsAt) external; // keeper
function reveal(bytes32 seed) external; // keeper
function fallbackReveal() external; // anyone, after timeout
}
interface IRewardVault {
function pending(uint256 tokenId) external view returns (uint256);
function claim(uint256[] calldata tokenIds) external; // pull, never push
}How the lock works
The collection overrides its transfer hook. While a pool is open and locked, any transfer reverts — including a marketplace sale.
// RainPoolsNFT.sol — OpenZeppelin v5
address public staking; // set once, then immutable
function _update(address to, uint256 tokenId, address auth)
internal override returns (address)
{
if (staking != address(0) && IPoolStaking(staking).isOpen(tokenId)) {
address from = _ownerOf(tokenId);
// mint and burn stay allowed; only wallet-to-wallet transfer is blocked
if (from != address(0) && to != address(0)) revert PoolIsOpen(tokenId);
}
return super._update(to, tokenId, auth);
}- The staking address is set once and can never be swapped for a malicious contract.
- Staking has no transfer rights at all — it only answers “is this pool open?”
- A hard ceiling on lock duration means a stuck lock always expires on its own.
Randomness
Weather cannot depend on block.timestamp or blockhash — a validator can game those. The scheme:
- Commit. The keeper publishes
keccak256(seed)before the event starts. Nobody knows the seed yet. - Reveal. At the end the keeper reveals the seed. The contract checks the hash and derives type, severity and length.
- Fallback. If the keeper goes silent past a timeout, anyone can force a neutral close. A dead keeper never freezes funds.
- Per-pool rolls. Dodge and drops come from
keccak256(seed, tokenId)— the keeper cannot know in advance who gets lucky, and players can verify.
Launch order
| # | Step | Why here |
|---|---|---|
| 1 | Pin art and metadata to IPFS | the metadata URI must exist before minting |
| 2 | Deploy RainPoolsNFT and verify the source | verified code before the first sale |
| 3 | Set contractURI: name, description, logo, banner, royalties | OpenSea reads collection branding straight from the contract |
| 4 | Mint, then renounce ownership | after reveal there is nothing left for an owner to do |
| 5 | Deploy WeatherOracle, start the keeper | weather must run before the first stake |
| 6 | Deploy PoolStaking, link it once | the link is irreversible, so it comes after testing |
| 7 | Issue $RPOOL and RewardVault | rewards go live last, once the game is already running |
| 8 | Put the addresses in the site config | the frontend flips to live mode with no code changes |
What the deployer can and cannot do
| Capability | Allowed | Note |
|---|---|---|
| Take a user's NFT | no | no approval, no transfer rights |
| Change the staking address | no | set once, then immutable |
| Mint unlimited $RPOOL | no | hard cap in the token |
| Change reward formulas | timelock only | 48h delay, visible in advance |
| Pause the game | yes | pausing never blocks closing a pool or claiming |
| Change royalties | up to a cap | ceiling is a constant |