KOLO is a non-custodial savings protocol on Arc (Circle's USDC-native L1).
A saver opens a vault locked to a purpose ("Adaeze final year fees"); anyone —
including diaspora family cross-chain via CCTP — contributes USDC toward the
goal; when it fills, the vault executes its purpose: funds move to an
immutable execution target (settlement treasury or direct merchant wallet),
and the backend pays the actual bill, anchored to purposeRef.
Founder: Chiemelie (Nigeria). Stack: Solidity 0.8.28, NestJS + BullMQ/Redis +
Prisma/Postgres, ethers v6. Frontend is founder-supplied (Vite + React + TS
expected) and lives in frontend/ when it lands.
contracts/ Solidity vaults + factory, solc-js compile, e2e tests, deploy
backend/ NestJS: indexer, auto-executor, settlement pipeline, REST API
frontend/ (founder-supplied UI goes here)
docker-compose.yml Postgres 16 + Redis 7 for local backend dev
# contracts/
npm run compile # solc 0.8.28 → ./abi/*.json (abi + bytecode)
npm test # 24-assertion e2e vs anvil (anvil --chain-id 5042002)
npm run deploy:arc # deploy factory to Arc Testnet (.env PRIVATE_KEY)
# repo root
docker compose up -d # postgres + redis
# backend/
cp .env.example .env # fill FACTORY_ADDRESS + EXECUTOR_PRIVATE_KEY
npm run prisma:generate && npm run prisma:migrate
npm run start:dev # API :3001 + indexer + workers in one process- KoloVault.sol — one vault per goal. States
Open → Executed | Cancelled | Expired. All config immutable (no proxies — deliberate). Key functions:contribute,contributeFor(contributor, amount)(CCTP attribution: relayer pays, funder's own EVM address keeps refund rights),execute(goal reached: saver or factory.executor; flex partial: saver only, post-deadline),cancel(saver),expire(permissionless post-deadline; flex vaults +14-day grace),claimRefund(pull-based). - KoloVaultFactory.sol —
new KoloVault(...)(no clones), registry (allVaults,vaultsBySaver,isKoloVault), config (feeRecipient, executor, default bps).Ownable2Step + Pausable; pause affectscreateVaultONLY.
- Vault funds have exactly two exits: the immutable
executionTarget, or refunds to contributors. No new withdrawal paths, ever. claimRefundstays callable inCancelled/Expiredregardless of any factory state. Refunds are unpausable.- Fees are snapshotted at vault creation, hard-capped at 500 bps in the vault itself. Factory changes never affect existing vaults.
- Forfeit applies only to the saver's own contribution, only on
cancel. Expiry refunds are always fee-free for everyone. - Expiry stays permissionless (post-deadline, +FLEX_GRACE_PERIOD for flex) so funds can never be stranded.
- Only the saver may partial-execute a flex vault; the executor role may only trigger execution when the goal is fully reached.
Any edit touching these paths must extend contracts/test/kolo.e2e.mjs and
keep all existing assertions green.
Single NestJS process, three concerns:
- indexer/ — polls Arc
getLogsfrom a DB cursor (IndexerCursor), ingestsVaultCreated(factory) + all vault events into Postgres. Event args are authoritative (totalRaised comes from the event) → idempotent, safe to re-run. On goal reached → enqueueskolo-execute. OnVaultExecuted→ createsSettlementJob, enqueueskolo-settle. - executor/ — BullMQ worker holding the factory
executorkey. Re-checks on-chain state before spending gas; contract only accepts the call at full goal, funds only move to the vault's immutable target — worst case is a reverted tx, never misdirected money. - settlement/ — BullMQ worker paying the real bill via a
BillerAdapter(settlement/billers/).MockBillerAdapterfor testnet; real aggregator adapters (Airbills' rails) implement the same interface and swap in viasettlement.module.ts. Terminal failures → status FAILED for ops (money sits in the settlement treasury — delayed, never lost).
REST for the UI (vaults/): GET /vaults?saver=, GET /vaults/:address
(funding-page data source), POST /purpose-ref (UI calls this BEFORE
createVault; stores full bill payload, returns keccak256 purposeRef +
factory address). Amount fields serialize as strings (USDC 6-dec base units).
| Testnet chain ID | 5042002 |
| RPC | https://rpc.testnet.arc.network |
| USDC ERC-20 | 0x3600000000000000000000000000000000000000 |
| EURC (testnet) | 0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a |
| Explorer | https://testnet.arcscan.app |
| Faucet | https://faucet.circle.com |
Decimals trap: Arc's native-gas USDC reports 18 decimals; the ERC-20
interface reports 6. Same balance, two representations. All code uses the
ERC-20 interface (6 dec) exclusively — parseUnits(x, 6) everywhere; never
mix provider.getBalance values in without converting.
- ethers v6 default provider caches ~250ms and batches RPC; against
instant-mining anvil this causes phantom stale-nonce/stale-timestamp
failures. Test providers use
{ batchMaxCount: 1, cacheTimeout: -1, staticNetwork: true }. - Custom errors surface in
estimateGasfailures as raw selectors (e.info.error.data) — match by selector (seeexpectRevertin the e2e). partialis a reserved word in Solidity ≥0.8.prisma generateneeds network for engines; in restricted sandboxes useprisma generate --no-enginefor types-only. Backend model rows have structural interface fallbacks invaults.controller.tssotscpasses even pre-generate.
- USDC amounts:
bigint, 6 decimals; API serializes as strings. - Addresses stored lowercase.
- Solidity: 0.8.28 pinned, custom errors, NatSpec on externals, checks-effects-interactions, SafeERC20.
- Never commit
.env; keys only in local env.
Done: contracts compiled clean + 24/24 e2e passing; backend typechecks clean (indexer, executor, settlement, REST all implemented; MockBillerAdapter).
- Deploy factory to Arc Testnet (
contracts: npm run deploy:arc) → put address inbackend/.envFACTORY_ADDRESSand set the factory executor to the backend key (factory.setExecutor). - Wire founder's UI in
frontend/: reads viaGET /vaults/:address+ live chain reads; writes via ethers againstcontracts/abi/*.json(createVault,approve+contribute). The shareable funding page a diaspora aunty opens from WhatsApp is the hero screen — highest polish. - Real biller adapter behind
BILLER_ADAPTERwhen rails are chosen. - Phase 2 contracts: CCTP v2 hooks, USYC yield adapter, ajo/esusu circles, EURC vaults.