A rotating savings circle that a stranger can safely join.
A Soroban contract for the savings mechanism most of the unbanked world already uses - adashe in Hausa, esusu and ajo in Yoruba and Igbo, susu in Ghana and the Caribbean, tanda in Mexico, chit fund in India, hui in China.
An app for it: https://circle-fi.github.io/circleFi-app/ - browse a circle, or open one from a wallet, with no CLI. The SDK wraps these contracts for everything else.
factory CCJRXTYIEFE6Z7DGTAKRGBLOGYBZNOONHI7FWZUDXEKZ7LGEGZNXKG3M
circle wasm 8c67956897b34dd87029a92620f89ea6a8d2936963fbc161661c531d3a4e5943
network Test SDF Network ; September 2015
token native (XLM) Stellar Asset Contract
The factory holds the circle's code hash and deploys a fresh circle per call, so anyone with a wallet can open one - no CLI, no upload step. Circles it has opened, newest first:
stellar contract invoke --id CCJRXTYIEFE6Z7DGTAKRGBLOGYBZNOONHI7FWZUDXEKZ7LGEGZNXKG3M \
--network testnet --source <your-key> -- list --offset 0 --limit 10The first circle opened through it, and one deployed by hand before the factory existed:
circle #1 CBV6IG53JF7WIINUBJS27YUCPCWUJM5EUSOWQKH7FKQLENKUR6Y2MA6Y
by hand CDIDPDIPE7BWHLGDY32I6JUIJLATMHUYL4ZHK7BFLFR3EXEM7QRAYRLI
terms 3 members, 1 XLM per round, 5-minute rounds
Read one without a wallet, an account or a fee:
stellar contract invoke --id CBV6IG53JF7WIINUBJS27YUCPCWUJM5EUSOWQKH7FKQLENKUR6Y2MA6Y \
--network testnet --source <your-key> -- get_stateA fixed group agrees on an amount and a period. Every period each member pays in, and one member - a different one each time - takes the whole pot. After N periods everyone has paid N times and received once. Nobody earns interest. What it buys is a lump sum today instead of in N months, with no bank, no credit check, and no paperwork. Hundreds of millions of people save this way.
It fails in exactly one way, and it fails this way constantly: a member takes their payout and stops contributing. Everyone who has not yet received is now short, and the only enforcement available is social. That single failure is why these groups stay small, stay informal, and stay restricted to people who already know each other. It caps the mechanism at the size of your address book.
Joining requires locking a security deposit of one contribution.
When a member misses a round, the shortfall is drawn from their own deposit automatically. The recipient is paid in full and on time regardless of who defaulted. The defaulter must restore their deposit before the circle will take them as current again, and the miss is recorded permanently in a public counter.
Absconding stops being everybody else's problem and becomes only the absconder's. That is the whole idea: a circle you can join with people you have never met, because the contract holds the collateral that trust used to.
If a member misses a round and their deposit is already spent, the contract does not invent money. The pot is genuinely short, the recipient receives less, and the member is flagged delinquent. Reporting that honestly matters more than appearing to guarantee something the contract cannot.
create --> Forming --(fills)--> Active --(N rounds)--> Complete
| | |
join() contribute() withdraw_deposit()
locks a deposit settle() pays
round N's member
join- locks one contribution as a deposit. Returns your position, which is also the round you get paid. Payout order is join order, published before anyone commits money, so you know your turn before you pay anything.contribute- pay into the open round.settle- closes the round: covers any misses from the defaulters' deposits, pays that round's recipient, opens the next round. Permissionless by design - the recipient, another member, or an unrelated keeper can call it, because the outcome is fixed by the contract and does not depend on who asks. No payout can be held hostage by an absent administrator.top_up- restore a deposit that covered a miss.withdraw_deposit- reclaim your deposit once the circle completes.
settle may be called early the moment everyone has paid, so an on-time circle
never waits for a clock.
| Function | Auth | Returns |
|---|---|---|
join(member) |
member | position in the circle |
contribute(member) |
member | - |
top_up(member) |
member | amount restored |
settle() |
none | the address paid |
withdraw_deposit(member) |
member | amount returned |
get_config() / get_state() / get_member(addr) |
none | view |
has_contributed(round, addr) / recipient_of(round) |
none | view |
Money is any SEP-41 token - a Stellar Asset Contract for USDC, or a local currency issued by an anchor, which is what makes this usable where the savings practice actually lives.
Storage keys are chosen so that contributions do not serialise against each
other. contribute writes only Paid(round, member) and Member(member) -
both parameterised by the caller - so every member of a circle can pay in the
same ledger without their transactions clustering together.
The contract deliberately does not keep a paid_this_round counter. A tally
like that is the obvious way to write it, and it would make every single
contribution write the same ledger entry, serialising the entire group behind
one number. settle reconstructs the count by reading each member's flag
instead. settle does write shared state, but it runs once per round, where
serialising is correct anyway.
This was verified with Braid, a static analyser for exactly this class of bug.
./scripts/deploy.sh 3 10000000 300 # capacity, contribution, round secondsPrints the circle contract id, which is what the app and the SDK take.
cargo test # 25 tests
cargo clippy --all-targets -- -D warnings
cargo build --target wasm32v1-none --releaseSoroban rejects the stock wasm32-unknown-unknown target on Rust 1.82+, which
enables reference-types and multivalue; wasm32v1-none is the target to build
for.
The suite covers the full lifecycle, both default paths, the authorisation boundary, and an accounting invariant asserting that after a complete cycle every member is square to the stroop and the contract holds nothing.
Apache-2.0.