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].

ResidencyFactory0x7a2e3f097abd3c1a59d5a762f29d1f02e5a63f89
USDC0x0abd146eb01d8b923c2162489e006b7b01c77a57

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.
StatusHost canMembers can
Openapprove, revoke, cancelstake
Activewithdraw, closewait; anyone can close after endTime
Failednothingclaim a full refund
Closedsweep what's unclaimed after 180 daysclaim 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 metadataHashHash of the listing JSON
uint64 startTime, endTimeStay dates; endTime − startTime ≥ 7 days
uint64 deadlineLast moment to stake; now < deadline ≤ startTime
uint32 minSeats, maxSeats1 ≤ minSeats ≤ maxSeats ≤ 500

Functions

createResidency(ResidencyParams p) returns (address residency)
Caller: AnyoneStatus: Any time

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)
Caller: Anyone (read-only)Status: Any time

How many residencies the factory has deployed.

residencyAt(uint256 index) view returns (address)
Caller: Anyone (read-only)Status: Any time

The residency deployed at position index (0-based, in launch order). Reverts past the end of the list.

usdc() view returns (address)
Caller: Anyone (read-only)Status: Any time

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

hostThe wallet that called createResidency, until it hands over with transferHost().
pendingHostThe wallet offered the host role, waiting to accept.
usdcThe token this residency accepts.
metadataHashkeccak256 of the canonical listing JSON (name, rooms, prices, organizers, city). Edits made after deploy are detectable.
startTime / endTimeWhen the stay runs (unix seconds). At least 7 days apart.
deadlineLast moment to stake. Must be in the future at deploy and no later than startTime.
minSeats / maxSeatsPaid seats needed for the residency to go ahead, and the cap. 1 ≤ min ≤ max ≤ 500.
seatCountHow many members have staked.
totalStakedSum of all stakes. The denominator for pro-rata leftovers.
totalWithdrawnSum of all host withdrawals.
closedBalanceBalance frozen at close(). What leftovers are split from.
cancelled / closedFlags set by cancel() and close().
closedAtWhen close() was called. The sweep window counts from here.
sweptSet by sweep(). Once true, leftover claims are over.
MIN_DURATION / MAX_SEATS / MAX_NOTE_LENGTH / SWEEP_DELAYConstants: 7 days, 500 seats, 280 bytes, 180 days.

Host functions

approve(address member, uint32 bedId, uint256 price)
Caller: Host onlyStatus: Open

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)
Caller: Host onlyStatus: Open

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()
Caller: Host onlyStatus: Open

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)
Caller: Host onlyStatus: Active

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()
Caller: Host any time; anyone after endTimeStatus: Active

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()
Caller: Host onlyStatus: Closed, 180 days after close()

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)
Caller: Host onlyStatus: Any time

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()
Caller: The pending hostStatus: Any time

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)
Caller: An approved memberStatus: Open

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()
Caller: A member who stakedStatus: Failed or Closed

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)
Caller: Anyone (read-only)Status: Any time

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)
Caller: Anyone (read-only)Status: Any time

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)
Caller: Anyone (read-only)Status: Any time

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)
Caller: Anyone (read-only)Status: Any time

The residency's current USDC balance.

bedHolder(uint32 bedId) view returns (address)
Caller: Anyone (read-only)Status: Any time

Which wallet holds a bed, approved or staked. Zero address if the bed is free.

pendingHost() view returns (address)
Caller: Anyone (read-only)Status: Any time

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.

ResidencyCreatedFactory launched a residency
Approved / RevokedHost changed a bed assignment
StakedA member paid; includes their seat number
CancelledHost called it off
WithdrawnHost took funds; includes the receipt hash and note
ClosedResidency ended; includes the balance being split
ClaimedA member took a refund or their leftovers
HostTransferStarted / HostTransferredHost role offered, then accepted
SweptHost collected leftovers 180 days after close
ErrorMeaning
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 approve is 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.