Docs / Smart contracts
Smart contracts
Every function in the two contracts that hold residency money: who can call it, when, and what it does.
Overview
AI City has two contracts, written in Solidity 0.8.28 with OpenZeppelin's SafeERC20 and ReentrancyGuard. There's no proxy, no upgrade key and no platform admin. The only privileged wallet is each residency's own host.
- ResidencyFactory is deployed once per chain. It launches residencies and keeps a list of them.
- Residency is one contract per stay. It's an escrow for seats: the host approves members for beds at a price, members stake that price in USDC before a deadline, and the minimum-seat rule decides whether the money goes to the host or back to the members.
Cities, proposals, room names and profiles live offchain in Postgres. The chain only holds money and a hash of the listing.
Unaudited. Only stake what you can afford to lose.
Deployed addresses
Network: Sepolia (chain id 11155111). Each residency's own address is shown on its page at /r/[address].
| ResidencyFactory | 0x7a2e3f097abd3c1a59d5a762f29d1f02e5a63f89 |
| USDC | 0x0abd146eb01d8b923c2162489e006b7b01c77a57 |
Lifecycle
Open ── approve · revoke · stake
│
├─ host calls cancel() ──────────────▶ Failed ── claim(): full refund
│
└─ deadline passes
├─ seats < minSeats ────────────▶ Failed ── claim(): full refund
└─ seats ≥ minSeats ──▶ Active ── withdraw() (host, repeatable)
│
└─ close() ──▶ Closed ── claim(): pro-rata leftovers
sweep() after 180 days (host)
Any status: transferHost() → acceptHost() hands the host role to another wallet.| Status | Host can | Members can |
|---|---|---|
| Open | approve, revoke, cancel | stake |
| Active | withdraw, close | wait; anyone can close after endTime |
| Failed | nothing | claim a full refund |
| Closed | sweep what's unclaimed after 180 days | claim a pro-rata share of what's left, within 180 days |
ResidencyFactory
Launches residencies. The host of each new residency is whoever calls createResidency.
ResidencyParams
bytes32 metadataHash | Hash of the listing JSON |
uint64 startTime, endTime | Stay dates; endTime − startTime ≥ 7 days |
uint64 deadline | Last moment to stake; now < deadline ≤ startTime |
uint32 minSeats, maxSeats | 1 ≤ minSeats ≤ maxSeats ≤ 500 |
Functions
createResidency(ResidencyParams p) returns (address residency)Deploys a new Residency contract with the caller as its host. Each residency is its own contract, so funds from different stays never mix.
- The app only lists residencies deployed from an approved proposal: the server decodes the event and checks the metadata hash, dates and seats against the proposal before recording it.
- The new address is appended to the factory's list, so every residency ever launched can be enumerated onchain.
Reverts: InvalidParams (from the Residency constructor) if the dates or seat counts break the rules below.
Emits: ResidencyCreated(residency, host, metadataHash, startTime, endTime, deadline, minSeats, maxSeats)
residenciesLength() view returns (uint256)How many residencies the factory has deployed.
residencyAt(uint256 index) view returns (address)The residency deployed at position index (0-based, in launch order). Reverts past the end of the list.
usdc() view returns (address)The USDC token every residency from this factory accepts. Fixed at factory deploy.
Residency
One seat escrow per stay. All parameters are fixed at deploy. The status is computed from the clock and two flags, so nobody has to call anything for the deadline to take effect.
State
host | The wallet that called createResidency, until it hands over with transferHost(). |
pendingHost | The wallet offered the host role, waiting to accept. |
usdc | The token this residency accepts. |
metadataHash | keccak256 of the canonical listing JSON (name, rooms, prices, organizers, city). Edits made after deploy are detectable. |
startTime / endTime | When the stay runs (unix seconds). At least 7 days apart. |
deadline | Last moment to stake. Must be in the future at deploy and no later than startTime. |
minSeats / maxSeats | Paid seats needed for the residency to go ahead, and the cap. 1 ≤ min ≤ max ≤ 500. |
seatCount | How many members have staked. |
totalStaked | Sum of all stakes. The denominator for pro-rata leftovers. |
totalWithdrawn | Sum of all host withdrawals. |
closedBalance | Balance frozen at close(). What leftovers are split from. |
cancelled / closed | Flags set by cancel() and close(). |
closedAt | When close() was called. The sweep window counts from here. |
swept | Set by sweep(). Once true, leftover claims are over. |
MIN_DURATION / MAX_SEATS / MAX_NOTE_LENGTH / SWEEP_DELAY | Constants: 7 days, 500 seats, 280 bytes, 180 days. |
Host functions
approve(address member, uint32 bedId, uint256 price)Approves a wallet for a specific bed at a specific price (USDC with 6 decimals, so 1,400 USDC is 1400000000). Approval is what lets the member stake.
- Re-approving a member who hasn't paid yet moves them to the new bed and price and frees their old bed.
- One bed holds one member. Approving someone for a bed another wallet holds reverts.
- The host can approve more people than maxSeats. Seats go to whoever pays first.
Reverts: NotHost, WrongStatus · InvalidMember: zero address or zero price · AlreadyStaked: the member has already paid · BedTaken(bedId, holder): another wallet holds that bed
Emits: Approved(member, bedId, price)
revoke(address member)Withdraws an approval that hasn't been paid yet and frees the bed. A member who has staked can't be revoked.
Reverts: NotHost, WrongStatus · NotApproved: nothing to revoke · AlreadyStaked: the member has paid
Emits: Revoked(member, bedId)
cancel()Calls the residency off before the deadline. The status becomes Failed permanently, and every staker can claim a full refund.
Reverts: NotHost, WrongStatus
Emits: Cancelled()
withdraw(uint256 amount, bytes32 receiptHash, string note)Sends amount USDC from the residency to the host to pay for the stay, recording the sha256 of the receipt file and a short note onchain.
- The contract doesn't check the receipt. It records the hash so anyone can match the uploaded file against it. The app rejects receipt uploads whose hash doesn't match the Withdrawn event.
- The host can withdraw any amount up to the full balance, in one or more withdrawals, from the moment the deadline passes. See the trust model below.
- The note is capped at 280 bytes.
Reverts: NotHost, WrongStatus · InvalidAmount: zero, or more than the balance · NoteTooLong: note over 280 bytes
Emits: Withdrawn(amount, receiptHash, note)
close()Ends an Active residency. The balance at that moment is frozen as closedBalance and each staker can claim their share of it.
- Anyone can close after the end date, so a host who disappears can't lock the leftovers.
- After close there are no more withdrawals.
Reverts: WrongStatus · CloseNotAllowed: a non-host called before endTime
Emits: Closed(closedBalance)
sweep()Sends everything still in the contract to the host: shares nobody claimed, rounding dust, and USDC sent here by mistake. Members have 180 days after closing to claim before their share can be swept.
- After a sweep, claimable() returns 0 and claim() reverts for anyone who hadn't claimed.
- A Failed residency can never be swept. Every refund stays claimable forever.
Reverts: NotHost, WrongStatus · SweepTooEarly(availableAt): less than 180 days since close · InvalidAmount: nothing to sweep
Emits: Swept(amount)
transferHost(address newHost)Offers the host role to another wallet. Nothing changes until that wallet calls acceptHost(). Pass the zero address to cancel a pending offer.
- Use it to rotate a host key, or to hand a residency to a co-organizer.
Reverts: NotHost
Emits: HostTransferStarted(currentHost, newHost)
acceptHost()Completes a host transfer. The caller becomes the host, with every host power and future withdrawals, and the previous host loses them. The two steps mean a typo'd address can never become the host.
Reverts: NotPendingHost: the caller isn't the offered wallet
Emits: HostTransferred(previousHost, newHost)
Member functions
stake(uint256 expectedPrice)Pays your approved price into the residency and takes a seat. You need to approve the residency to spend that much USDC first. The app does this as step 1 of 2.
- expectedPrice is the price you saw and agreed to. If the host re-approved you at a different price before your transaction landed, stake reverts with PriceChanged instead of charging you the new amount.
- Once staked, you can't be revoked or moved to another bed, and you can't unstake. Your way out is a refund if the residency fails or is cancelled.
Reverts: WrongStatus: past the deadline, or cancelled · NotApproved, AlreadyStaked · PriceChanged(currentPrice): your approved price isn't expectedPrice · ResidencyFull: maxSeats already paid
Emits: Staked(member, bedId, price, seatNumber)
claim()Pays out whatever claimable(you) returns, once. Full refund if Failed; your pro-rata share of leftovers if Closed.
Reverts: NothingToClaim: not staked, already claimed, or the residency is Open or Active
Emits: Claimed(member, amount)
Views
status() view returns (Status)The current state, computed from the clock and flags rather than stored: Closed if closed; Failed if cancelled; Open before the deadline; then Active if seatCount ≥ minSeats, otherwise Failed.
getMember(address account) view returns (Member)A wallet's record: approved, staked, claimed, bedId and price. The app uses staked to gate who can download receipts.
claimable(address account) view returns (uint256)What claim() would pay this wallet right now. Failed: the full price paid. Closed: closedBalance × price ÷ totalStaked, until the host sweeps. Otherwise, or if already claimed: 0.
balance() view returns (uint256)The residency's current USDC balance.
bedHolder(uint32 bedId) view returns (address)Which wallet holds a bed, approved or staked. Zero address if the bed is free.
pendingHost() view returns (address)The wallet offered the host role by transferHost(). Zero address if no transfer is pending.
Events and errors
The app reads these events to record launches and match receipts. You can read the same events on a block explorer to audit any residency without trusting the app.
ResidencyCreated | Factory launched a residency |
Approved / Revoked | Host changed a bed assignment |
Staked | A member paid; includes their seat number |
Cancelled | Host called it off |
Withdrawn | Host took funds; includes the receipt hash and note |
Closed | Residency ended; includes the balance being split |
Claimed | A member took a refund or their leftovers |
HostTransferStarted / HostTransferred | Host role offered, then accepted |
Swept | Host collected leftovers 180 days after close |
| Error | Meaning |
|---|---|
NotHost() | A host-only function was called by someone else. |
InvalidParams() | Constructor rules broken: zero host or token, duration under 7 days, deadline in the past or after start, bad seat counts. |
WrongStatus(current) | The function isn't allowed in the current status. Carries the status it found. |
InvalidMember() | approve() with a zero address or zero price. |
BedTaken(bedId, holder) | Another wallet holds that bed. |
NotApproved() | stake() or revoke() on a wallet with no approval. |
AlreadyStaked() | Double stake, or trying to change or revoke a paid member. |
ResidencyFull() | maxSeats already paid. |
NothingToClaim() | claim() when claimable() is 0. |
InvalidAmount() | withdraw() of zero or more than the balance. |
NoteTooLong() | withdraw() note over 280 bytes. |
CloseNotAllowed() | A non-host called close() before endTime. |
PriceChanged(currentPrice) | stake() was sent with a price that no longer matches your approval. |
NotPendingHost() | acceptHost() from a wallet that wasn't offered the role. |
SweepTooEarly(availableAt) | sweep() before 180 days have passed since close(). |
Trust model and known limits
What the contract guarantees:
- No one can take money while the residency is Open, including the host.
- You never pay more than the price you passed to
stake(). - If the minimum isn't met, or the host cancels, every staker gets back exactly what they paid.
- Funds from one residency can't touch another's.
- Refunds are pull-based, so one member's failed transfer can't block anyone else.
- If the host disappears, anyone can close after the end date and members claim what's left.
What it doesn't guarantee, so you know what you're trusting the host with:
- Once Active, the host controls the money. From the moment the deadline passes, even before the stay starts, the host can withdraw the whole balance. Receipts make spending visible; they don't prove it, and the contract doesn't check them.
- World ID and bed lists aren't checked onchain. The app checks them. Onchain, the host's
approveis the gate. - Claim leftovers within 180 days. After that the host can sweep whatever is unclaimed from a closed residency. Refunds from a failed residency are never sweepable.
- A lost host key still can't be recovered. The host can hand over the role while they control the key, but nobody can take it from them. Members are still protected by the deadline and the anyone-can-close rule.