Smart Contract Architecture
Token inventory, beacon proxy pattern, Superfluid stream topology, and permission model.
Smart Contract Architecture
Design principles
- Everything that can be tokenized is tokenized.
- Everything that can stream, streams — via Superfluid USDCx, resource Supertokens, or the global ENERGY SuperToken. ENERGY is action fuel, not a resource-tax token.
- Each asset is both an ERC-721 registry entry AND its own deployed contract instance (beacon proxy pattern).
- No player ever interacts with Superfluid directly —
PlayerProxyexposes semantic game functions (setTaxPayment,updateFoodStream, etc.) that call Superfluid internally. Contracts may call Superfluid directly; players never need to. - All privileged actions are gated by
PermissionRegistry(owner, operator, target, selector → bool) from theerc-permissionslibrary — enabling granular per-function automation delegation. - Economic routing stays intentionally simple: every major asset may define one optional
beneficiaryaddress. If set, payouts route there; if unset, payouts route to the current asset owner. - Ongoing costs are open: maintenance, food, and similar upkeep flows may be funded by any address, without pre-registration, and multiple contributors can additively support the same asset.
- Sovereignty logic should be geometric and auditable: crown positions and void seams must be derivable from on-chain coordinates, not off-chain social consensus.
- ENERGY slot yield, grace windows, and placeholder action costs live in
EnergyConstants.sol; clients mirror them in one constants module.
Token inventory
Eight token surfaces:
| Token | Standard | One per | Notes |
|---|---|---|---|
| PlayerProxy | ERC-721 (also a contract instance) | Player | Game identity. Holds all assets, manages all streams. Free to create. |
| TileNFT | ERC-721 registry | — | Each tile has a registry entry + a TileInstance beacon proxy. |
| ArmySlot | ERC-721 registry | — | Each slot has a registry entry + an ArmySlotInstance beacon proxy. |
| PatentNFT | ERC-721 registry | — | Each patent has a registry entry + a PatentInstance beacon proxy. |
| ResourceToken[name] | Supertoken | Resource name | Deployed on first mine via ResourceFactory. One per named resource. |
| ENERGY | Supertoken | Global | Streamed from tile instances to tile owners at 1 ENERGY/day per empty slot. Burned for movement and paid for patent Harberger holding tax; rejected by resource tax and army maintenance paths. |
| ArmyUnitToken | Supertoken | Global | Single token. Minted via resource burn. Streamed to 0xdead in combat. |
| USDCx | Supertoken | — | Wrapped USDC. Used in auctions and UBI distribution. |
Beacon proxy pattern
Every asset class has two layers:
Registry contract (ERC-721) — thin licence layer. Tracks tokenId → instanceAddress, handles licence transfers via settle().
Instance contract (BeaconProxy) — one deployed per asset. Has its own address and state. Receives and sends Superfluid streams directly. All game logic lives here (in the shared implementation behind the beacon). Upgrading the beacon upgrades all instances simultaneously.
TileRegistry (ERC-721) ← who owns tile #42
tokenId → TileInstance ← where tile #42 actually lives
TileImplementation (logic) ← shared, behind the beacon
TileBeacon ← points all proxies at current implementation
TileInstance #42 (BeaconProxy) ← own address, own state, own streams
TileInstance #43 (BeaconProxy)
At mint, the registry deploys a new BeaconProxy pointing to the beacon and calls initialize() on it. The instance address is stored in instances[tokenId].
Every instance contract inherits PermissionedTarget (from erc-permissions), making every game action granularly delegatable.
Contract package structure
packages/contracts/src/
├── tokens/
│ ├── PlayerProxy.sol # ERC-721 + game identity contract
│ ├── TileNFT.sol # ERC-721 registry for tiles
│ ├── ArmySlot.sol # ERC-721 registry for army slots
│ ├── PatentNFT.sol # ERC-721 registry for patents
│ ├── ResourceToken.sol # Supertoken template (cloned per resource name)
│ ├── ResourceFactory.sol # Calls SF SuperTokenFactory on first mine
│ └── ArmyUnitToken.sol # Single global supertoken
├── core/
│ ├── SprawlRegistry.sol # Global state, coordinate index, void seams, crown geometry
│ ├── SuperfluidHelpers.sol # Internal Superfluid utilities shared across contracts
│ ├── PermissionRegistry.sol # From erc-permissions (vendored)
│ └── TileGeometry.sol # Hex math library (distance, ring enumeration)
├── instances/
│ ├── TileImplementation.sol # Logic for TileInstance beacon proxies
│ ├── ArmySlotImplementation.sol
│ ├── PatentImplementation.sol
│ └── PlayerProxyImplementation.sol
├── economics/
│ ├── EnergyConstants.sol # Slot counts, ENERGY/day, grace period, action costs
│ ├── FrontierAuction.sol # Tile auctions: popcorn, credits, sequential
│ ├── ArmyAuction.sol # Slot auctions + daily unit emission
│ ├── TileHarberger.sol # Buyout offers, bitmap dispute, settlement
│ ├── PatentHarberger.sol # Phase 2 patent Harberger (ENERGY × 54)
│ └── Treasury.sol # UBI GDA pool admin, 7-day smooth rate
├── military/
│ ├── ArmyController.sol # State machine: Moving/Trenching/Raiding/Attacking
│ └── CombatEngine.sol # Death rate math, combat stream management
└── patents/
├── PatentOffice.sol # Verifies LLM signature, mints PatentNFT
└── MiningRegistry.sol # PoW proof verification, opens resource streams
PermissionRegistry integration
A single global PermissionRegistry is deployed at game launch. Every asset instance inherits PermissionedTarget(permissionRegistry).
Key rule: the PlayerProxy IS the owner in all registry lookups. All permission grants go through the proxy:
// on PlayerProxy — called by the player's wallet
function grantPermission(address operator, address target, bytes4 selector) external onlyWallet {
registry.grant(operator, target, selector); // msg.sender = PlayerProxy = owner
}
The onlyAuthorized(owner) modifier on every instance function allows either msg.sender == owner (direct call) or a valid registry entry (delegated call).
Example permission grants:
| Owner | Operator | Target | Selector | Use case |
|---|---|---|---|---|
| PlayerProxy | maintenance bot | PlayerProxy | setTaxStream() | Automated maintenance management |
| PlayerProxy | mining agent | TileInstance | submitMiningProof() | In-browser or agent mining |
| PlayerProxy | mercenary proxy | ArmySlotInstance | move() | Mercenary delegation |
| PlayerProxy | mercenary proxy | ArmySlotInstance | attack() | Mercenary delegation |
| PlayerProxy | mercenary proxy | ArmySlotInstance | trench() | Mercenary delegation |
| PlayerProxy | mercenary proxy | ArmySlotInstance | raid() | Mercenary delegation |
| PlayerProxy | stream keeper | TileInstance | restoreStream() | Permissionless stream health bots |
| TileRegistry | HarbergerContract | TileRegistry | _forceSettle() | Acquisition forced transfers |
Superfluid abstraction boundary
Contracts call Superfluid directly — there is no required wrapper layer. However, players never interact with Superfluid primitives. The PlayerProxy exposes semantic game functions that handle stream management internally:
| Player calls | PlayerProxy does internally |
|---|---|
| setTaxPayment(tileId, token, sourceTileId, rate) | Opens/updates GDA pool membership for that tile |
| updateFoodStream(slotId, rate) | Updates the common resource stream to the army slot |
| openTaxStream(tileInstance, rate) | Called by settle() on tile acquisition |
| grantPermission(operator, target, selector) | Calls PermissionRegistry.grant() |
Shared Superfluid utilities (flow rate math, deposit calculation, CFA/GDA boilerplate) live in SuperfluidHelpers.sol and are imported by any contract that needs them. This is a library, not an access control layer.
Delegation handles action authority only. More sophisticated pooling, rev-share, treasury logic, or multi-party coordination should be built one layer up in wrapper contracts or higher-level organizations, not inside base asset rights.
The faction layer follows the same rule. The base protocol should expose enough geometry and payout hooks to support faction treasuries, but not hard-code every internal treasury policy.
ResourceToken stream-mint pattern
Each ResourceToken is deployed by ResourceFactory on the first mining event for a new resource name.
uint256 constant INITIAL_MINT = 1_000_000_000e18;
constructor() { _mint(address(this), INITIAL_MINT); }
function totalSupply() public view override returns (uint256) {
return INITIAL_MINT - balanceOf(address(this));
}
totalSupply() tracks tokens that have left the contract — approximately equal to circulating supply. A permissionless sweep() function on each tile instance flushes accumulated token balances back to the ResourceToken contract to keep the counter accurate.
Stream initiation on a new mine:
MiningRegistryregisters the proof and computesflowRate.ResourceTokentransfers the Superfluid deposit amount toTileInstance(so it can open its own outgoing streams).ResourceTokenopens a stream toTileInstanceatflowRate.TileInstance(a Superfluid super-app) receives theafterAgreementCreatedcallback.TileInstanceopens: 95% stream to the tile beneficiary (or tile owner if no beneficiary is set), 5% stream toPatentInstance.
Stream topology (per mined resource)
ResourceToken (1B self-minted)
│ ① sends deposit tokens to TileInstance (for its outgoing streams)
│ ② opens stream at flowRate R
▼
TileInstance (Superfluid super-app)
│ ③ afterAgreementCreated callback
├── 0.95R → PlayerProxyInstance (licensee)
└── 0.05R → PatentInstance (Superfluid super-app)
│ aggregates all incoming 5% streams
└── single stream (sum) → PatentHolder's PlayerProxy
PatentInstance is a super-app that maintains one outgoing stream to the patent beneficiary (or patent holder if no beneficiary is set). When the PatentNFT transfers, PatentInstance.onOwnershipTransfer() closes the old stream and opens a new one to the new holder or configured beneficiary — incoming tile streams are unaffected.
Tiles, armies, and patents all follow the same routing rule:
- if
beneficiaryis set on the asset, outbound value is routed there - otherwise, outbound value is routed to the current asset owner
This keeps the base protocol compatible with agents, treasuries, and pooled structures without hard-coding more complex rights systems into v1.
Maintenance — GDA model
ENERGY cannot be registered as a resource tax token or configured as army maintenance. It is a global SuperToken action fuel: tile instances stream it to owners from empty slots, movement burns a flat 50 ENERGY, and patent Harberger holding tax is paid in ENERGY. Resource tokens remain the tile maintenance and tax substrate.
The player's PlayerProxy is the GDA admin for their maintenance pool(s). Tile instances are pool members.
Maintenance funding is open and additive. Any address may contribute maintenance flows for a tile. No contributor allowlist or pre-registration is required; declared value is based on the live incoming maintenance state, not on who sent it.
Each tile registers up to 3 resource tokens as maintenance sources. Registration is permanent — MiningRegistry.isMined(token, sourceTileId) is verified once at registration (mining is a permanent on-chain state; it never reverts). The local bonus is cached at registration.
struct TaxEntry {
address resourceToken;
uint8 rarityTier; // 0=Common … 3=Legendary; cached from token
uint256 sourceTileId; // tile producing this resource (for local bonus)
uint16 localBonusBps; // cached: ring 0=200, 1=150, 2=125, 3=112, 4+=100
}
TaxEntry[3] public taxEntries;
uint8 public taxCount; // 0–3
On registration, the contract verifies:
MiningRegistry.isMined(token, sourceTileId)is true.uint8 ring = hexDistance(this.coordinates, registry.coordsOf(sourceTileId)).localBonusBpsis set from ring (up to 3-ring bonus: ×2 / ×1.5 / ×1.25 / ×1.12).
Declared value (used for acquisition cost and UBI units):
uint256[4] constant RARITY_MULT = [1, 3, 9, 18];
function totalTaxWeight() public view returns (uint256 weight) {
for (uint8 i = 0; i < taxCount; i++) {
int96 rate = playerTaxPool(taxEntries[i].resourceToken).getMemberFlowRate(address(this));
weight += uint256(int256(rate))
* RARITY_MULT[taxEntries[i].rarityTier]
* taxEntries[i].localBonusBps / 100;
}
}
Default = one stream closure. Closing the player's GDA flow rate to zero cuts all tile members simultaneously. A liquidator calls PlayerProxy.closeTaxStream() and all tiles lose their maintenance payment in one transaction.
UBI GDA pool — 7-day smooth
A global GDA pool distributes USDCx to all maintenance-paying players.
function updateUBIRate() public {
uint256 balance = usdcx.balanceOf(address(treasury));
int96 rate = int96(int256(balance / 7 days));
streamManager.updateDistributionRate(usdcx, ubiPool, rate);
}
Called automatically on every treasury deposit (auction settlement, patent maintenance inflow). Fully programmatic — no admin or governance. Treasury always targets 7 days of runway. If inflows stop, the pool runs dry in one week.
UBI units for each player equal their totalTaxWeight (declared value) summed across all tiles. Updated whenever the player's maintenance GDA flow changes, in the same transaction.
Crown geometry hooks
SprawlRegistry is also the natural place to expose the sovereignty geometry needed by higher-level faction logic:
frontierRadius()returns the current global maximum hex distance from origin across all placed tiles. It is not a completeness check; one far-out placed tile can increase it.crownCoordinates()returns the six axial crown corners at that current radius.isCrownCoordinate(q, r)checks whether a coordinate is part of the currently active crown.setVoidCoordinate(q, r, bool)andisVoidCoordinate(q, r)track the current reserved void-seam set. The bool parameter is an explicit scaffold-stage escape hatch for owner curation before the live ruleset is locked.
This keeps the temporary winning state objective and machine-verifiable before the full faction treasury layer is wired in.
Reasoning trace
- The crown mechanic needs auditable on-chain geometry before it needs rich political contracts.
- Void seams are map truth, so they belong in the registry layer instead of an off-chain map file.
- Faction treasury policy should stay above the base asset contracts; geometry and stream hooks should stay below it.
Acquisition claim — neighbor bitmap and dispute
Claims use a 3-ring neighbor bitmap (36 bits, fits in uint64) encoding which of the 36 neighbors are foreign-licensed. The claimant provides this and the contract records it without on-chain verification at claim time (optimistic model).
struct BuyoutOffer {
uint256 tileId;
uint256 offerAmount; // stated offer; 10% is escrowed as deposit
uint64 neighborBitmap; // 36 bits: 1 = foreign-licensed
uint48 submittedAt; // block.timestamp at submission
}
effectiveDays is computed from the bitmap: max(9, 54 - foreignCount × 2.5 + ownCount × 1.0). Acquisition cost = tileHarberger.totalTaxWeight(tileId) × effectiveDays × 86400.
Dispute (permissionless, 24-hour window):
function dispute(uint256 offerId, uint8 bitIndex) external {
// look up the actual neighbor tile at bitIndex
uint256 neighborTileId = tileGeometry.getNeighborAtIndex(offer.tileId, bitIndex);
bool bitmapClaims = (offer.neighborBitmap >> bitIndex) & 1 == 1;
bool actualForeign = ownerOf(neighborTileId) != ownerOf(offer.tileId);
require(bitmapClaims != actualForeign, "bitmap correct");
if (ownershipLastChanged[neighborTileId] >= offer.submittedAt) {
// licence changed after claim — good-faith escape hatch
_cancelOffer(offerId); // deposit returned, no penalty
} else {
// confirmed cheat
uint256 penalty = offer.offerAmount * 20 / 100;
_cancelAndSlash(offerId, msg.sender, penalty); // capped by posted deposit
}
}
ownershipLastChanged[tileId] is updated in _settle() on every licence transfer.
Settlement — TileHarberger.settle()
All tile licence transfers go through TileHarberger.settle(). Standard transferFrom also works for voluntary peer-to-peer transfers, but Harberger's settle() can execute a forced transfer without licensee approval (the protocol's core acquisition guarantee).
settle() atomically:
- Closes old licensee's GDA maintenance pool membership (removes tile from their pool).
- Closes production streams from tile to the old beneficiary target.
- Transfers the NFT (
TileRegistry._forceTransfer()— privileged, only callable by Harberger). - Opens production streams from tile to the new beneficiary target (or the new owner if no beneficiary is set).
- Calls
newOwnerProxy.addTileToTaxPool(tileId)to register the tile in the new licensee's GDA pool. - Updates
ownershipLastChanged[tileId].
If the new licensee has no PlayerProxy, settle() reverts.
Combat — streams to 0xdead
When ArmyController.initiateAttack() is called, both ArmySlotInstance contracts open streams of ArmyUnitToken to address(0xdead):
baseRate = min(armyA, armyB) / (48 × 3600) // units/second
slotA → 0xdead at baseRate × modifierA
slotB → 0xdead at baseRate × modifierB
The slot's live ArmyUnitToken balance IS the current army size — no separate storage needed.
Modifiers (trenching bonus, cooldown malus, starvation malus) adjust each side's rate independently. Rate updates require an explicit updateCombatRate() call from either party — this must be done when:
- Reinforcements activate (24-hour delay expires).
- A modifier changes (trenched/un-trenched, cooldown expires).
Retreat = either party closes their combat stream. Combat ends immediately.
Food is the player's manual responsibility. As units burn and army size decreases, food cost (which scales superlinearly with unit count) drops — but the player must call updateFoodStream() themselves to reduce their food outflow. Starvation from under-management is a game risk.
Stream restoration
Every instance exposes permissionless restoration functions:
// anyone can call — enables keeper bots
function restoreProductionStream(address resourceToken) external; // on TileInstance
function restartMiningStream(uint256 tileId) external; // on ResourceToken
function restoreRoyaltyStream() external; // on PatentInstance
These re-open streams that were accidentally closed (e.g. Superfluid liquidation). Each verifies the stream should exist (resource is mined, patent is in Phase 2, etc.) before acting.