Smart Contract Architecture
Token inventory, beacon proxy pattern, Superfluid stream topology, and permission model.
Smart Contract Architecture
PARTIALLY SUPERSEDED. PRs #34/#35/#36 replaced the
IPlayerProxyHookpre/post hook surface and the bespokesetTaxStream/setTaxPayment/closeTaxStream/updateFoodStream/addTileToTaxPool/removeTileFromTaxPoolproxy entrypoints with a faction-router middleware pattern plus typed player wrappers (playerSetTaxStream,playerSetTaxPayment,playerUpdateFoodStream,playerCloseTaxStream,playerCloseFoodStream). Tax-pool and food-recipient membership now live onTileHarberger.registerTaxPayment/unregisterTaxPaymentandArmySlot.registerFoodStream/unregisterFoodStream. The chapter below still describes the legacy surface; treat names likesetTaxPayment,updateFoodStream,closeTaxStream,addTileToTaxPool,IPlayerProxyHook, andtaxPoolControlleras historical until this page is rewritten.
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 settlement flows. |
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
├── interfaces/
│ ├── ISprawlRegistry.sol # Faction/effective-side and map registry surface
│ └── IPlayerProxyHook.sol # Optional pre/post hook surface for statutes
├── 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 # Protocol treasury for native/ERC20 settlement funds
├── 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, statutes, or higher-level organizations, not inside base asset rights.
The faction layer follows the same rule. The base protocol records join-only faction membership, a statute address, and shared color metadata; it does not hard-code ranks, treasuries, voting, taxes, or exit governance.
PlayerProxy statute hooks
PlayerProxyImplementation exposes an optional callHook() target and an execute(target, value, data) pathway for proxy-mediated actions.
When a hook is configured, execution order is:
beforePlayerProxyCall(playerId, caller, target, value, data)- the actual target call from the player proxy address
afterPlayerProxyCall(playerId, caller, actualTarget, actualValue, actualData, result)
The pre-call hook may validate, block, or rewrite the target, value, and calldata. The post-call hook observes the actual successful call and return bytes so a statute can account for shared resources, taxes, delegated construction, or mutual-defense bookkeeping after execution.
Hook failures revert. Target failures also revert and return the target result bytes through PlayerProxyCallFailed(bytes). There is no silent hook failure mode.
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 tax weight):
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.
Treasury custody
Treasury accepts native-token deposits and ERC20 settlement deposits. It no longer computes a crown reward rate or streams treasury balances to players.
Faction and crown registry hooks
SprawlRegistry is also the natural place to expose the side identity and sovereignty geometry needed by higher-level faction logic:
-
createFaction(statute, colorPalette)is payable and creates a permissionless faction with a domain-separated id. Any native-token payment is forwarded tofactionCreationTreasury, keeping ETH/xDAI out of the registry and available for treasury/mainframe bounty flows. Faction ids set the high bit (FACTION_ID_FLAG) so they cannot collide with normal player ids. -
joinFaction(playerId, factionId)records membership. It can only be called directly by the owner wallet of that PlayerProxy NFT. ERC-721 approved operators and proxy-mediated calls cannot faction-lock a player. The base protocol intentionally exposes no leave function in v1. -
faction(factionId)returns the statute address and shared color palette metadata. -
factionOf(playerId)returns the joined faction or zero. -
effectivePlayerId(playerId)returns the faction id when present, otherwise the player id. -
sameSide(playerA, playerB)is the canonical non-hostility helper for faction-aware side checks. -
kernelHackEffectiveSide(playerId)is the canonical kernel hack and victory attribution helper. -
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 and future kernel hack objective machine-verifiable before richer faction statutes are wired in.
Reasoning trace
- The crown and kernel hack mechanics need auditable effective-side resolution before they need rich political contracts.
- Void seams are map truth, so they belong in the registry layer instead of an off-chain map file.
- Faction governance and treasury policy should stay above the base asset contracts; membership, side identity, geometry, color metadata, and proxy hook surfaces 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. Each army has a selected common-tier FOOD token and an explicit tank balance. Direct deposits refill the tank immediately, and FOOD streams are credited when settleFood() or reportMaintenance() runs. As units burn and army size decreases, requiredFoodRate() drops, increasing tank runway; agents can target a balance threshold instead of continuously matching an exact stream rate. Player-proxy stream updates settle before changing rates, while arbitrary direct ledger writes remain an approximate v1 input because the ledger only exposes current aggregate rates.
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.