Charter is a set of Soroban smart contracts for on-chain treasury management. An organization deploys a treasury with a set of approvers and an approval threshold, organizes its funds into budget categories with lifetime spending caps, and releases funds only when enough approvers sign off. A factory contract deploys and tracks these treasuries so many organizations can run independent treasuries from a single, verifiable deployment. Every balance, category, request, and approval is stored on-chain and publicly readable.
| Name | Role | GitHub |
|---|---|---|
| Fuhad | Lead maintainer | @fadesany |
Charter splits treasury management across two contracts:
- A treasury holds a single token for one organization. Funds are grouped into budget categories, each with a lifetime cap. Spending happens through a request-and-approval flow: a member submits a request against a category, approvers sign it, and once the approval threshold is met the payout executes automatically.
- A factory deploys and registers treasuries. Each treasury is deployed from a single verified wasm hash with a deterministic address, so every organization runs the same reviewed code.
This design suits DAOs, grant programs, and any group that needs multi-party control over shared funds with an auditable, on-chain record.
Network: Testnet (Test SDF Network ; September 2015)
| Contract | Address | Wasm hash |
|---|---|---|
| Factory | CCUQBFFRGR4RUWHKLWSRWKBL3WORHNTHFLTKMHTNUZL4T5733ODN5WD4 |
e6ee93a93dd18927abab8dc1c4f95ec820da020310b2b2a45a0588b91581df8a |
| Treasury (reference deployment) | CAH4PUADD2X3K52TKETWTIL4GHPZT55LWUEVVOSH6B3D3KA2ZH7HQGTT |
b72f664802f395192375b4fca2e0930cff6f994a8053ca568d4f96eb0032ba6c |
Treasuries are normally created through the factory's deploy_treasury. The treasury above is one reference deployment kept for verification; both wasm hashes are reproducible from stellar contract fetch.
The factory listed above predates permissionless deployment and the constructor: it still requires its original deployer to sign every
deploy_treasury, and it was bound to its wasm hash by a separateinitializecall. The current source does neither, and has not been deployed yet.
- Rust
1.92.0(pinned inrust-toolchain.toml, which also adds thewasm32v1-nonetarget) - Stellar CLI
26.x(stellar 26.1.0is the tested version)
CI builds and tests on Linux, and the contracts are platform-independent Soroban wasm.
.cargo/config.tomlcarries a Windows (windows-gnu) linker override for contributors who build natively on Windows; it is inert on other hosts.
# Compile the contracts to wasm
stellar contract build
# Run the test suite (47 treasury + 17 factory = 64 tests)
cargo testThe scripts under scripts/ deploy and exercise the contracts end to end. Run them in order:
# 1. Create and fund testnet identities (deployer, admin, approvers, requester)
./scripts/setup-testnet.sh
# 2. Build, upload the treasury wasm, and deploy the factory with that hash as a
# constructor argument (one step; there is no separate initialize).
# Writes the resulting factory address + treasury wasm hash to scripts/.env
./scripts/deploy.sh
# 3. Verify the deployment by exercising the factory's read paths.
# Pass `deploy-treasury` to also deploy a treasury through the factory.
./scripts/verify.sh
./scripts/verify.sh deploy-treasuryThe treasury wasm must be uploaded before the factory is deployed, because its hash is a constructor argument: deploy.sh uploads the treasury wasm, then deploys the factory with -- --wasm_hash <hash>. The hash is set in the same transaction that creates the factory, so no one can set a different one first.
contracts/
├── treasury/ # Per-organization treasury: categories, caps, approval flow
├── factory/ # Deploys and registers treasuries from the treasury wasm hash
└── test-token/ # Minimal mintable token used only in tests and verification
scripts/
├── setup-testnet.sh # Create + fund testnet identities
├── deploy.sh # Build, upload, deploy (wasm hash via constructor)
└── verify.sh # Exercise factory read/deploy paths on-chain
A treasury holds one token for one organization and is controlled by a set of approvers with an approval threshold. Its lifecycle:
- Initialize with an admin, approvers, threshold, and token.
- Create categories, each with a lifetime spending cap.
- Deposit the token into the treasury.
- Submit a request to spend from a category.
- Approve — when approvals reach the threshold, the payout executes automatically and the category's spent total increases. Requests can also be rejected or cancelled.
Caps are lifetime totals: a category tracks cumulative spent against its cap and never resets.
The factory's constructor binds it to the treasury wasm hash when the factory is deployed. Each deploy_treasury call deploys a treasury at a deterministic address (salted by a sequential org id), initializes it, and records an on-chain org registry entry. Reads are available through get_org, get_org_count, and the paginated get_orgs.
Every entry point below is a public contract function. The env: Env host parameter is injected by the runtime, so signatures are shown as a caller sees them — e.g. client.initialize(&admin, &approvers, &threshold, &token) from a generated client, or stellar contract invoke … -- initialize --admin … --approvers … --threshold … --token … from the CLI. All i128 amounts are in the token's smallest unit (scaled by its decimals).
Per-organization vault. Configuration (admin, approvers, threshold, token) lives in instance storage; categories and requests live in persistent storage.
Configuration & approvers — admin only:
fn initialize(admin: Address, approvers: Vec<Address>, threshold: u32, token: Address)
fn add_approver(admin: Address, approver: Address) // no-op if already an approver
fn remove_approver(admin: Address, approver: Address) // fails if it would drop below threshold
fn set_threshold(admin: Address, threshold: u32)Budget categories — admin only:
fn create_category(admin: Address, name: String, cap: i128) -> u32 // cap > 0; returns category_id
fn update_category_cap(admin: Address, category_id: u32, new_cap: i128) // new_cap >= spent
fn set_category_active(admin: Address, category_id: u32, active: bool)Funds & requests:
fn deposit(from: Address, amount: i128) // auth: from
fn submit_request(requester: Address, category_id: u32, recipient: Address, amount: i128, memo: String) -> u32 // auth: requester; returns request_id
fn approve_request(approver: Address, request_id: u32) // auth: approver; auto-executes at threshold
fn reject_request(approver: Address, request_id: u32) // auth: approver
fn cancel_request(requester: Address, request_id: u32) // auth: original requesterViews (no auth):
fn get_category(category_id: u32) -> Category
fn get_categories() -> Vec<Category>
fn get_request(request_id: u32) -> Request
fn get_requests_by_category(category_id: u32) -> Vec<Request>
fn get_balance() -> i128
fn get_approvers() -> Vec<Address>
fn get_threshold() -> u32
get_categoriesandget_requests_by_categoryiterate every entity with no upper bound; on treasuries with many categories or requests they can exceed transaction resource limits. Pagination is tracked in issue #3.
Data types:
struct Category { name: String, cap: i128, spent: i128, active: bool }
enum RequestStatus { Pending, Executed, Rejected, Cancelled }
struct Request {
id: u32,
category_id: u32,
recipient: Address,
amount: i128,
memo: String,
requester: Address,
approvals: Vec<Address>,
status: RequestStatus,
created_ledger: u32,
}Events:
| Event | Topics | Data |
|---|---|---|
CategoryCreated |
category_id |
name, cap |
CapUpdated |
category_id |
new_cap |
ActiveChanged |
category_id |
active |
Deposited |
from |
amount |
RequestSubmitted |
request_id |
category_id, recipient, amount |
RequestApproved |
request_id |
approver |
RequestExecuted |
request_id |
recipient, amount |
RequestRejected |
request_id |
approver |
RequestCancelled |
request_id |
— |
Errors:
| Code | Name | Raised when |
|---|---|---|
| 1 | AlreadyInitialized |
initialize is called a second time |
| 2 | NotInitialized |
a function is called before initialize |
| 3 | NotAdmin |
a non-admin calls an admin-only function |
| 4 | NotApprover |
approve/reject is called by an address outside the approver set |
| 5 | CategoryInactive |
a request is submitted against an inactive category |
| 6 | CapExceeded |
reserved — cap overruns currently surface as InvalidAmount |
| 7 | RequestNotPending |
the target request is not pending (or does not exist) |
| 8 | InvalidThreshold |
threshold is 0, exceeds the approver count, or a removal would drop below it |
| 9 | AlreadyApproved |
the same approver approves a request twice |
| 10 | NotRequester |
a non-submitter tries to cancel a request |
| 11 | InvalidAmount |
non-positive amount, insufficient remaining cap, or unknown category |
Deploys treasuries from a single uploaded treasury wasm and records each as an org. Org creation is permissionless: any account can deploy a treasury for an org it administers by signing as that org's admin. There is no factory-level deployer or operator.
fn __constructor(wasm_hash: BytesN<32>) // runs once, when the factory is deployed; not callable afterwards
fn deploy_treasury(name: String, admin: Address, approvers: Vec<Address>, threshold: u32, token: Address) -> u32 // auth: admin; per-admin cooldown; returns org_id
fn get_org(org_id: u32) -> OrgRecord
fn get_org_count() -> u32
fn get_orgs(start: u32, limit: u32) -> Vec<OrgRecord> // limit capped at 50deploy_treasury requires only the new treasury's admin to sign. That signature also covers the factory's sub-call to the treasury's initialize, which itself requires admin auth. The org id doubles as the deploy salt, so every treasury address is deterministic.
Spam limit: each admin address can deploy at most one org per DEPLOY_COOLDOWN_LEDGERS (720 ledgers, about an hour). A deploy that fails does not start the cooldown. The limit throttles a single admin identity; it does not stop someone who uses many admin accounts. Every deploy also costs the caller network fees and storage rent.
Deployment: the treasury wasm hash is a constructor argument (stellar contract deploy … -- --wasm_hash <hash>), so it is set in the same transaction that creates the factory. There is no initialize step, and nothing can set or replace the hash afterwards. The constructor does not check that the hash has been uploaded, so upload the treasury wasm first.
Data types:
struct OrgRecord { name: String, treasury: Address, admin: Address, created_ledger: u32 }Events:
| Event | Topics | Data |
|---|---|---|
TreasuryDeployed |
org_id |
name, treasury, admin |
Creating the factory does not emit an event; issue #2 tracks adding one (it predates the constructor and still refers to
initialize).
Errors:
| Code | Name | Raised when |
|---|---|---|
| 1 | — | retired (was NotInitialized; the constructor sets the wasm hash, so a factory can't be uninitialized); not reused |
| 2 | — | retired (was AlreadyInitialized; there is no initialize to call twice); not reused |
| 3 | — | retired (was NotDeployer, removed when deployment became permissionless); not reused |
| 4 | OrgNotFound |
get_org is called with an unknown id |
| 5 | TreasuryInitFailed |
the new treasury's initialize fails (e.g. invalid threshold); the treasury's own error code is not passed through |
| 6 | DeployCooldown |
the same admin deploys again less than DEPLOY_COOLDOWN_LEDGERS after its last successful deploy |
contracts/test-token is a minimal mintable token used only by the test suite and the on-chain verification scripts — it is not part of the production surface (see SECURITY.md). It implements just enough of a token interface for a treasury to hold and move balances:
fn init(admin: Address, decimal: u32, name: String, symbol: String)
fn mint(to: Address, amount: i128) // auth: admin
fn transfer(from: Address, to: Address, amount: i128) // auth: from
fn balance(id: Address) -> i128
fn decimals() -> u32
fn name() -> String
fn symbol() -> String
fn admin() -> AddressContributions are welcome. To get started:
- Browse the open issues — issues labelled
good first issueare a good entry point. - Fork the repo and create a branch (
feat/…,fix/…, ordocs/…). - Make your change and ensure
cargo testpasses andstellar contract buildsucceeds. - Open a pull request against
mainwith a clear description. Commits follow Conventional Commits (feat(scope):,fix(scope):,docs(scope):).
See SECURITY.md for how to report vulnerabilities.
Licensed under the MIT License.