Appearance
Economy Service
Status: Economy V1 (Audition) shipped; Economy V2 (Competition tickets and settlement) pending.
Related: Clash Of Clones Product Spec, Capacity And Model Tiers, Clone Service Module, Economy Module, Backend Overview
Scope
EconomyService owns money-shaped facts. Economy V1 (Audition) owns audition deposits, purchases, and entitlement grants. Economy V2 (Competition) will own ticket sales, pool snapshots, settlement events, payout signatures, creator royalties, and protocol rake. Clone Service owns the projection (effective entitlement) that AI/Prop guards read.
text
EconomyService (facts) Clone Service (projection)
├─ deposits/payments └─ effective entitlement row
├─ payouts (UI + AI + Prop guards)
├─ entitlement grants
├─ tickets / pools
├─ payout signatures
└─ settlement eventsOn-chain Contract
The economy module talks to a SignedVault contract deployed on MegaETH. SignedVault is a resolver-mediated escrow:
- Users deposit ETH (or ERC20 with Permit2) tagged with
(resolver, nonce). - Withdrawals require an EIP-712 signature from
resolver; the backend signs off-chain and the user broadcasts the withdrawal. resolverBalanceOf[resolver][token]caps total outflow per resolver, so a compromised resolver key cannot drain more than that resolver's deposits.- Pump Party uses one dedicated
pump_partyresolver address. Other dApps can share the same vault with their own resolver — independent ledgers per resolver.
Contract source lives at contracts/SignedVault.sol. The backend integrates via ABI + address only.
Deposit (income) Flow
text
1. Frontend wallet
SignedVault.depositETH(resolverAddr, nonce) { value: feeWei }
2. Frontend → backend
POST /api/v1/economy/audition-fee/submit
{ depositNonce, tier, assetCapacity, modelStrength, cloneId? | createClone? }
3. Backend
- readContract: getDeposit(user, ETH, resolver, nonce) → wei
- Reject if wei == 0 or wei != expectedFeeWei
- Insert economy_purchases row (UNIQUE on (resolver_address, deposit_nonce))
- Call clone module → upsert clone_capacity_entitlements
(source='audition_fee', economyEventId=purchase id)
4. Response
- 200 with purchase + entitlement snapshot
- 409 if nonce already redeemed
- 422 if on-chain amount mismatchThe frontend chooses nonce; the backend uses (resolver, nonce) for idempotency. A submit retry with the same nonce after success returns the existing purchase + entitlement (idempotent).
Withdrawal Signature Flow
Withdrawal triggers are owned by future settlement code, but the signing helper lives in the Economy module so race tickets, creator royalties, and protocol rake can reuse it.
text
1. Lifecycle event (e.g. race settled)
-> calls economy.withdrawalSigner.sign({ user, amountWei, kind })
2. Backend
- Generate EIP-712 nonce (160-bit address || 64-bit ms timestamp || 32-bit random)
- Sign Withdraw(user, ETH, amount, resolver, nonce, deadline) with resolver key
- Persist signature on the relevant row (payout / royalty)
3. Frontend (later)
- Reads signature → calls SignedVault.withdrawETH(...) from user wallet
- Contract verifies signature, marks nonce used, transfers ETHv1 implements audition-fee deposit verification. Race ticket payouts and creator/protocol payouts should use the withdrawal signer and add product-specific ledger rows.
Economy V1 Audition Fee Pricing
Matrix from the product spec:
| Model strength | 1-3 assets | 4-6 assets | 7-10 assets |
|---|---|---|---|
cheap (Haiku) | 0.01 ETH | 0.015 ETH | 0.02 ETH |
medium (Sonnet) | 0.02 ETH | 0.03 ETH | 0.04 ETH |
best (Opus) | 0.04 ETH | 0.06 ETH | 0.08 ETH |
Pricing is committed in backend/src/modules/economy/pricing/audition-fee.ts. Treat it as a tunable constant, not a feature flag — adjust by editing code + bumping the PR.
Entitlement Mapping
A confirmed audition-fee purchase grants exactly one entitlement row via the existing Clone Service sync endpoint:
text
tier = 'starter' | 'builder' | 'pro' # derived from asset capacity
maxActiveAssets = chosen capacity (1-10)
allowedModelStrength = 'cheap' | 'medium' | 'best' # derived from model tier
source = 'audition_fee'
economyEventId = economy_purchases.idThe grant uses the Clone Service entitlement repository directly. There is no direct entitlement-write HTTP route in the monolith. For paid creation, Economy verifies the deposit first, creates the clone shell and default draft, writes the purchase row, then grants the entitlement in one backend flow.
Storage
One table for v1, one row per deposit:
sql
economy_purchases
id TEXT PK -- uuid v4
user_id TEXT NOT NULL FK -- users.id
clone_id INTEGER -- nullable: filled when clone exists
kind TEXT NOT NULL -- 'audition_fee' (more values in future)
tier TEXT NOT NULL -- starter/builder/pro
max_active_assets INTEGER NOT NULL
allowed_model_strength TEXT NOT NULL -- cheap/medium/best
amount_wei TEXT NOT NULL -- expected fee
on_chain_amount_wei TEXT NOT NULL -- amount returned by getDeposit
currency TEXT NOT NULL -- 'ETH'
chain_id INTEGER NOT NULL
vault_address TEXT NOT NULL -- contract instance
resolver_address TEXT NOT NULL
deposit_nonce TEXT NOT NULL -- decimal string of uint256
status TEXT NOT NULL -- pending/confirmed/granted/failed
granted_entitlement_clone_id INTEGER -- redundant with clone_id, kept for audit
failure_reason TEXT
created_at INTEGER NOT NULL
updated_at INTEGER NOT NULL
UNIQUE (resolver_address, deposit_nonce) -- idempotencyThe (resolver_address, deposit_nonce) unique index is the canonical idempotency key — frontend can safely retry with the same nonce.
Endpoints
text
POST /api/v1/economy/audition-fee/quote
Auth: Bearer
Request: { tier, assetCapacity }
Response: { tier, assetCapacity, modelStrength, amountWei, currency, vaultAddress, resolverAddress, chainId }
POST /api/v1/economy/audition-fee/submit
Auth: Bearer
Request: { depositNonce, tier, assetCapacity, modelStrength, cloneId? | createClone? }
Response: { purchase: { id, status, cloneId }, entitlement, clone?, draft? }
Errors: 401 unauthorized, 404 clone_not_found, 403 forbidden,
409 deposit_already_redeemed, 422 deposit_amount_mismatch,
422 deposit_not_found/quote exists so the frontend can show the user the exact amount and the on-chain target before prompting for the wallet transaction. /submit is the v1 post-deposit confirmation/sync step. It still accepts depositNonce because there is no server-side purchase intent or SignedVault watcher yet.
Configuration
| Variable | Description | Required |
|---|---|---|
MEGAETH_VAULT_ADDRESS | Deployed SignedVault address. | Yes |
MEGAETH_PUMP_PARTY_VAULT_RESOLVER_PRIVATE_KEY | Hex private key for the pump-party resolver. Must NOT also be the contract owner. | Yes (for /submit + signing) |
MEGAETH_RPC_URL | RPC endpoint for read+verify. Falls back to viem default if unset. | No |
MEGAETH_VAULT_CHAIN_ID | Active chain id (4326 mainnet, 6343 testnet). Defaults to testnet in dev. | No |
STRATEGY_BACKEND_SERVICE_TOKEN | Existing service token reused for cross-module entitlement sync. | Yes |
Production Checklist
- Resolver private key lives in the production secret store, not
.env. MEGAETH_VAULT_ADDRESSis checksum-normalized; mismatch triggers refusal at startup.- Resolver address is NOT also the contract owner / upgrade admin.
- Background job calls
auth_challenges.deleteExpireddaily (existing user-service concern, not economy). - (Future) Add a periodic reconciliation that walks
economy_purchases.status='pending'and re-checks chain state.
Economy V2 / Out of Scope For V1
- Race tickets (deposit verification reuses the same chain helper, but the schema/route lives in lifecycle work).
- Settlement / payouts / creator royalty / protocol rake.
- ERC20 (USDC) payments via Permit2.
- On-chain event listener / indexer — v1 is pull-on-submit only. The frontend tells the backend to verify after the wallet transaction confirms.
Open Decisions
- Vault contract owner: multisig vs. separate admin key. Out of backend PR's scope but blocks production deploy.
- Whether protocol rake/creator pool flow through the same SignedVault or a separate splitter contract. Not needed until lifecycle PR.