Architecture

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.

EscrowLock in place
Owner while stakedthe contractyou
What OpenSea showsheld by contractheld by you
If the contract is exploitedNFT can be stolenworst case: a stuck lock
Needs approval on the collectionyes, fullno
Why this is materially safer
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

ContractRoleKey properties
RainPoolsNFTERC-721, 3,333 itemsIPFS metadata, ERC-2981 royalties, lock support, ownership renounced after reveal
PoolStakingtracks open pools8h lock, blocks transfers, never holds an NFT, cannot transfer one
WeatherOraclepublishes weathercommit-reveal, bounded durations, public fallback
$RPOOLreward tokenERC-20, hard cap, mint restricted to the vault
RewardVaultaccrual and claimspull 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.
A lesson already paid for
In an earlier build a silent keeper could freeze player funds: exits were blocked until an event that never came. Here the fallback is part of the interface from day one, not a patch.

Launch order

#StepWhy here
1Pin art and metadata to IPFSthe metadata URI must exist before minting
2Deploy RainPoolsNFT and verify the sourceverified code before the first sale
3Set contractURI: name, description, logo, banner, royaltiesOpenSea reads collection branding straight from the contract
4Mint, then renounce ownershipafter reveal there is nothing left for an owner to do
5Deploy WeatherOracle, start the keeperweather must run before the first stake
6Deploy PoolStaking, link it oncethe link is irreversible, so it comes after testing
7Issue $RPOOL and RewardVaultrewards go live last, once the game is already running
8Put the addresses in the site configthe frontend flips to live mode with no code changes

What the deployer can and cannot do

CapabilityAllowedNote
Take a user's NFTnono approval, no transfer rights
Change the staking addressnoset once, then immutable
Mint unlimited $RPOOLnohard cap in the token
Change reward formulastimelock only48h delay, visible in advance
Pause the gameyespausing never blocks closing a pool or claiming
Change royaltiesup to a capceiling is a constant
One rule governs all of it: emergency levers must never be able to touch someone else's property. Pause stops new entries; it never stops you from leaving with yours.