diff --git a/.gitbook.yaml b/.gitbook.yaml new file mode 100644 index 0000000..8edf68b --- /dev/null +++ b/.gitbook.yaml @@ -0,0 +1,5 @@ +root: ./docs + +structure: + readme: README.md + summary: SUMMARY.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..315414d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,44 @@ +# What is Charter + +> Charter is a treasury operations layer for Stellar-based organizations. +> Instead of a bare multisig wallet, funds are held under a policy contract +> with defined budget categories, spend limits, and approval thresholds. +> Contributors submit disbursement requests against a category; designated +> approvers sign off within the policy's rules; once the threshold is met, +> funds release automatically. Every category, request, and disbursement is +> publicly readable on-chain. +> +> Charter is two things: a `treasury` contract, one instance per +> organization, that holds the actual policy and funds; and a `factory` +> contract that deploys new treasury instances and keeps a public registry +> of every organization using Charter. + +## Two contracts, one system + +An organization's treasury and the factory that created it do different jobs. + +The **treasury** is where an organization's money and rules live. One +treasury belongs to one organization. It holds a single token, tracks that +organization's budget categories and their caps, keeps the approver list and +the approval threshold, and records every disbursement request and its +approvals. Reads and writes for day-to-day operations go straight to the +treasury. + +The **factory** deploys treasuries and keeps a registry of them. Every +treasury is deployed from the same reviewed contract code, so an outside +observer can confirm that every organization on Charter runs identical rules. +The factory is the only part of the system involved in creating a treasury; +after that, an organization interacts with its own treasury directly. + +## Who this documentation is for + +- **Organization admins** set up a treasury, define budget categories, and + manage the approver set. Start with [Getting Started](for-organization-admins/getting-started.md). +- **Approvers and requesters** submit disbursement requests and sign off on + them. Start with [Submitting a Request](for-approvers-and-requesters/submitting-a-request.md). +- **Developers and reviewers** evaluating the code will find the contract + reference under [Smart Contracts](smart-contracts/overview.md) and + integration details under the [Developer Guide](developer-guide/local-setup.md). + +Charter runs on Stellar testnet and has not been audited. It is experimental +software; do not use it to custody real value. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 0000000..72eed00 --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,41 @@ +# Table of contents + +## Introduction + +* [What is Charter](README.md) +* [The Problem](introduction/the-problem.md) +* [How It Works](introduction/how-it-works.md) + +## Protocol + +* [Request Lifecycle](protocol/request-lifecycle.md) +* [Category Mechanics](protocol/category-mechanics.md) +* [Approval Threshold Model](protocol/approval-threshold-model.md) + +## Smart Contracts + +* [Overview](smart-contracts/overview.md) +* [treasury](smart-contracts/treasury.md) +* [factory](smart-contracts/factory.md) + +## For Organization Admins + +* [Getting Started](for-organization-admins/getting-started.md) +* [Setting Up Categories and Approvers](for-organization-admins/setting-up-categories-and-approvers.md) +* [Reading the Public Ledger](for-organization-admins/reading-the-public-ledger.md) + +## For Approvers and Requesters + +* [Submitting a Request](for-approvers-and-requesters/submitting-a-request.md) +* [Approving, Rejecting, and Cancelling](for-approvers-and-requesters/approving-rejecting-and-cancelling.md) + +## Developer Guide + +* [Local Setup](developer-guide/local-setup.md) +* [Environment Variables](developer-guide/environment-variables.md) +* [SDK Reference](developer-guide/sdk-reference.md) +* [API Reference](developer-guide/api-reference.md) + +## Contributing + +* [How to Contribute](contributing/how-to-contribute.md) diff --git a/docs/contributing/how-to-contribute.md b/docs/contributing/how-to-contribute.md new file mode 100644 index 0000000..9c21d53 --- /dev/null +++ b/docs/contributing/how-to-contribute.md @@ -0,0 +1,87 @@ +# How to Contribute + +Contributions are welcome. Charter is two repositories — the application layer +and the contracts — and this page covers how to work in either. The conventions +here restate what each repository's `README` and `CONTRIBUTING` enforce; where +this page and a repository differ, the repository is the source of truth. + +## The two repositories + +- **[`Ch-rter/app`](https://github.com/Ch-rter/app)** — the SDK, web app, and + indexer. TypeScript and Go. +- **[`Ch-rter/contract`](https://github.com/Ch-rter/contract)** — the treasury + and factory Soroban contracts. Rust. + +## Finding something to work on + +Start with the open issues labeled `good first issue`: + +- [`app` good first issues](https://github.com/Ch-rter/app/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) +- [`contract` good first issues](https://github.com/Ch-rter/contract/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) + +If you want to work on something not yet filed, open an issue first so the +approach can be agreed before you write code. + +## Branch naming + +Branch off `main` with a type prefix that matches the change: + +``` +feat/ +fix/ +docs/ +chore/ +``` + +## Commit format + +Use [Conventional Commits](https://www.conventionalcommits.org/) with a scope, +for example: + +``` +feat(sdk): add batch approval helper +fix(indexer): decode contract events positionally +docs(web): document the read-only source account +``` + +## Pull requests + +`main` is protected. A pull request must have an approving review and its status +checks must pass before it can merge; stale approvals are dismissed when new +commits land, and open review conversations must be resolved. The checks that +run on every push and PR: + +- **Web (lint · typecheck · build)** — `npm ci`, `npm run lint`, + `npm run typecheck`, `npm run build:web`. +- **Indexer (vet · build)** — `go vet ./...` and `go build ./...` in `indexer/`. + +Run those locally before you push and you will not be surprised by CI. + +### Checklist before opening a PR + +- [ ] `npm run lint` and `npm run typecheck` pass. +- [ ] `npm run build:web` succeeds (for web or SDK changes). +- [ ] `go vet ./...` is clean (for indexer changes). +- [ ] No `any` types introduced in TypeScript. +- [ ] Writes still go through `packages/sdk`; reads still come from the indexer. + +That last point is the one architectural rule worth repeating: the web app never +calls a state-changing contract method directly and never reads display data +from Soroban RPC. Writes go through the SDK; reads come from the indexer. A +change that blurs those two paths will be asked to change, however well it works. + +## Working on the contracts + +Contract changes live in `Ch-rter/contract` and carry an extra responsibility: +the application layer documents contract behavior — function signatures, error +discriminants, events — from the contract source. If you change a function +signature, an error enum, or an event's shape, the [Smart +Contracts](../smart-contracts/overview.md) pages and the SDK's mirrored types +need to change with it. Note the change in your PR description so the +application-layer docs and `packages/sdk/src/types.ts` are updated to match. + +## Reporting a security issue + +Do not open a public issue for a vulnerability. Each repository's +`SECURITY.md` explains how to report one privately. See +[`app`'s SECURITY.md](https://github.com/Ch-rter/app/blob/main/SECURITY.md). diff --git a/docs/developer-guide/api-reference.md b/docs/developer-guide/api-reference.md new file mode 100644 index 0000000..a4ded33 --- /dev/null +++ b/docs/developer-guide/api-reference.md @@ -0,0 +1,186 @@ +# API Reference + +The indexer serves a small, read-only REST API over the read models it folds +from on-chain events. Every response on this page is a real capture from the +live testnet indexer at: + +``` +https://charter-indexer.onrender.com +``` + +Every endpoint is `GET`, needs no authentication, and returns JSON. Amounts are +decimal **strings** in the token's smallest unit, never numbers — the reference +token has 7 decimals, so `"314159265"` means `31.4159265` tokens. Format them +with a bigint-safe helper; never parse them to a float. + +> The live indexer runs on a free tier that spins down when idle. The first +> request after a quiet period can return `503` with a `Retry-After` header +> while it cold-starts; retry after a few seconds and it comes up. + +## CORS + +The API sets `Access-Control-Allow-Origin: *`, allows methods `GET, OPTIONS`, +and allows the `Content-Type` header. A preflight `OPTIONS` request returns +`204`. A browser app on any origin can read it directly. + +## `GET /health` + +Liveness plus database reachability. + +```json +{"status":"ok"} +``` + +## `GET /orgs` + +Every indexed organization, newest first. The list is wrapped in an `orgs` key. + +```json +{ + "orgs": [ + { + "id": 1, + "name": "TestToken Org", + "treasuryAddress": "CBJQ6CZ3A6VRA3UJXRI3S6WXK3T5UH6EMHCA7TBGEPKFB6O2RMBJPI3P", + "adminAddress": "GDDWFYWXCSBI6RNS5TV2ZZSBYY35MDKHR2424O7RVL6LDC4DUTBTVR2Z", + "createdLedger": 4064653 + }, + { + "id": 0, + "name": "Charter Test Org 2", + "treasuryAddress": "CA5Y353OYIQLTVSQV77CJ5NEBL2UUAP3GE3NPYZOG2WCJZH2BRJ6BK2D", + "adminAddress": "GDN3D7XLV54KQ2QML6H3ZQ2OLUFQP7LAMDZWV7TXCHPK73GLHWZ7HDUK", + "createdLedger": 4063779 + } + ] +} +``` + +## `GET /orgs/{treasury}` + +One organization by its treasury address. Unlike the list endpoints, this +returns the object **bare** — no wrapper key. + +``` +GET /orgs/CBJQ6CZ3A6VRA3UJXRI3S6WXK3T5UH6EMHCA7TBGEPKFB6O2RMBJPI3P +``` + +```json +{ + "id": 1, + "name": "TestToken Org", + "treasuryAddress": "CBJQ6CZ3A6VRA3UJXRI3S6WXK3T5UH6EMHCA7TBGEPKFB6O2RMBJPI3P", + "adminAddress": "GDDWFYWXCSBI6RNS5TV2ZZSBYY35MDKHR2424O7RVL6LDC4DUTBTVR2Z", + "createdLedger": 4064653 +} +``` + +A treasury address that is not indexed returns `404`. + +## `GET /orgs/{treasury}/categories` + +That treasury's budget categories, in category-id order, wrapped in a +`categories` key. + +```json +{ + "categories": [ + { + "categoryId": 1, + "name": "Payroll", + "cap": "900000000", + "spent": "314159265", + "active": true + } + ] +} +``` + +`cap` and `spent` are decimal strings. Here the Payroll category has a cap of +`900000000` (90 tokens) and has spent `314159265` (31.4159265 tokens), leaving +`585840735` (58.5840735 tokens) of room. + +## `GET /orgs/{treasury}/requests` + +That treasury's disbursement requests, newest first, wrapped in a `requests` +key. An optional `?status=` filter narrows the list. + +```json +{ + "requests": [ + { + "requestId": 1, + "categoryId": 1, + "recipient": "GBBASI3ODOGYXGCMGBUNFYHY6E5LRVEW3PT5PPALIJEI63UOSBWK7QS5", + "amount": "314159265", + "memo": "", + "requester": "", + "status": "Executed", + "createdLedger": 4064715, + "approvals": ["GBBASI3ODOGYXGCMGBUNFYHY6E5LRVEW3PT5PPALIJEI63UOSBWK7QS5"] + } + ] +} +``` + +### The `status` filter is case-sensitive + +It accepts exactly `Pending`, `Executed`, `Rejected`, or `Cancelled` — +capitalized. A lowercase value like `?status=executed` is rejected: + +``` +GET /orgs/{treasury}/requests?status=executed +→ 400 +{"error":"invalid status filter"} +``` + +## `GET /orgs/{treasury}/requests/{id}` + +One request by its id, returned **bare** (no wrapper key). + +``` +GET /orgs/CBJQ6CZ3A6VRA3UJXRI3S6WXK3T5UH6EMHCA7TBGEPKFB6O2RMBJPI3P/requests/1 +``` + +```json +{ + "requestId": 1, + "categoryId": 1, + "recipient": "GBBASI3ODOGYXGCMGBUNFYHY6E5LRVEW3PT5PPALIJEI63UOSBWK7QS5", + "amount": "314159265", + "memo": "", + "requester": "", + "status": "Executed", + "createdLedger": 4064715, + "approvals": ["GBBASI3ODOGYXGCMGBUNFYHY6E5LRVEW3PT5PPALIJEI63UOSBWK7QS5"] +} +``` + +A request id that does not exist returns `404`. + +## Two things this real response tells you + +The captured request above is genuine testnet data, and it shows two properties +of the read model you need to account for when you build against this API. Both +come from the same cause: the indexer reconstructs requests from the events the +contract emits, and those events do not carry every field. + +**1. An executed request shows one fewer approval than the number who signed.** +This request executed, yet `approvals` lists a single address. The approval that +meets the threshold executes the request in the same step, and the contract +emits a `RequestExecuted` event for it rather than a `RequestApproved` event. +The indexer builds `approvals` from `RequestApproved` events only, so the final, +decisive approval is not counted. Read an executed request as: the listed +approver(s), plus whoever's approval triggered execution. + +**2. `requester` and `memo` can be empty even when they were set on-chain.** In +this capture both are `""`. The `RequestSubmitted` event carries only +`category_id`, `recipient`, and `amount` as data — not the requester address or +the memo. The indexer has no event field to populate those two from, so they +come back empty. If you need the requester or memo authoritatively, read the +request straight from the contract with `treasury.getRequest` (see the [SDK +Reference](sdk-reference.md)), whose `Request` type carries both. + +Neither of these is a bug in the API — they are consequences of building a read +model from an event stream. They are documented here so you design around them +rather than trusting a field the event never carried. diff --git a/docs/developer-guide/environment-variables.md b/docs/developer-guide/environment-variables.md new file mode 100644 index 0000000..cc82e21 --- /dev/null +++ b/docs/developer-guide/environment-variables.md @@ -0,0 +1,90 @@ +# Environment Variables + +Charter has two configuration surfaces: the web app and the indexer. Each ships +a committed `.env.example` you copy and fill in. No secrets are committed to +either repository, and none should be — the one genuinely sensitive value, the +indexer's database URL, stays out of version control. + +## Web app (`apps/web/.env.local`) + +Every web variable is public. They are all `NEXT_PUBLIC_*`, which Next.js inlines +into the client bundle at build time. Nothing here is a secret — these values +ship to the browser by design. + +Two things follow from that build-time inlining, both of which the code depends +on: + +- The variables are read through **static** `process.env.NEXT_PUBLIC_*` property + accesses, never a dynamic `process.env[name]` lookup. Next.js only inlines a + public var when it can see the literal property name at build time; a dynamic + lookup compiles to `undefined` in the browser. This is why + `packages/sdk/src/rpc.ts` reads each name literally. +- There is **no localhost fallback**. A missing required value throws at the + point of use rather than quietly defaulting to a local URL that could ship to + production. Set them, or the app fails loudly. + +| Variable | Purpose | +| --- | --- | +| `NEXT_PUBLIC_SOROBAN_RPC_URL` | Soroban RPC endpoint the SDK simulates and submits against. | +| `NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE` | Network passphrase every transaction is signed against. | +| `NEXT_PUBLIC_FACTORY_CONTRACT_ID` | The factory the new-org flow deploys treasuries through. | +| `NEXT_PUBLIC_TREASURY_WASM_HASH` | Treasury wasm hash — informational, shown in the new-org flow. | +| `NEXT_PUBLIC_INDEXER_API_URL` | Base URL of the indexer REST API the app reads from. | +| `NEXT_PUBLIC_READ_ONLY_SOURCE_ACCOUNT` | Any account public key, used only as the source for read-only simulations. | + +The reference `.env.example` values for testnet: + +```bash +NEXT_PUBLIC_SOROBAN_RPC_URL="https://soroban-testnet.stellar.org" +NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" +NEXT_PUBLIC_FACTORY_CONTRACT_ID="" +NEXT_PUBLIC_TREASURY_WASM_HASH="" +NEXT_PUBLIC_INDEXER_API_URL="http://localhost:8080" +NEXT_PUBLIC_READ_ONLY_SOURCE_ACCOUNT="" +``` + +> Keep `NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE` **quoted**. The passphrase +> contains a semicolon (`Test SDF Network ; September 2015`), which breaks +> unquoted shell sourcing of the file. + +The `NEXT_PUBLIC_READ_ONLY_SOURCE_ACCOUNT` is worth a note: read simulations +never touch the ledger, so the account only needs to be a valid `G…` strkey — +it does not need to exist or be funded. Leave it blank and the SDK derives a +deterministic unfunded placeholder from an all-zero seed. + +## Indexer (`indexer/.env`) + +The indexer is a server-side service, so its configuration is **not** public. + +| Variable | Purpose | +| --- | --- | +| `DATABASE_URL` | Postgres connection string. **Sensitive** — keep it out of version control. | +| `SOROBAN_RPC_URL` | Soroban RPC endpoint the poller reads events from. | +| `NETWORK_PASSPHRASE` | Network passphrase. | +| `FACTORY_CONTRACT_ID` | The factory the treasury watch list is bootstrapped from. | +| `PORT` | REST API port (default `8080`). | +| `POLL_INTERVAL_SECONDS` | How often the ingestion loop polls `getEvents` (default `5`). | + +The reference `.env.example` (with a throwaway local database URL — a real +deployment uses a private connection string that is never committed): + +```bash +DATABASE_URL="postgres://charter:charter@localhost:5432/charter?sslmode=disable" +SOROBAN_RPC_URL="https://soroban-testnet.stellar.org" +NETWORK_PASSPHRASE="Test SDF Network ; September 2015" +FACTORY_CONTRACT_ID="" +PORT="8080" +POLL_INTERVAL_SECONDS="5" +``` + +Same quoting rule applies to `NETWORK_PASSPHRASE` — the semicolon breaks +unquoted sourcing. + +## What points at what + +The web app never talks to Postgres and never writes to the indexer. It reads +display data from `NEXT_PUBLIC_INDEXER_API_URL` and sends writes through the SDK +to `NEXT_PUBLIC_SOROBAN_RPC_URL`. The indexer reads events from its own +`SOROBAN_RPC_URL` and writes them to `DATABASE_URL`. The one value they must +agree on is the network — both passphrases, and both RPC URLs, should point at +the same network, and both `FACTORY_CONTRACT_ID` values at the same factory. diff --git a/docs/developer-guide/local-setup.md b/docs/developer-guide/local-setup.md new file mode 100644 index 0000000..37280c5 --- /dev/null +++ b/docs/developer-guide/local-setup.md @@ -0,0 +1,102 @@ +# Local Setup + +This page gets the Charter application layer running on your machine: the web +app, the SDK it depends on, and the indexer that feeds it read data. It mirrors +the repository's own quick start; where this page and the repo `README` differ, +the `README` is the source of truth. + +The three parts live in the [`Ch-rter/app`](https://github.com/Ch-rter/app) +repository. The contracts they talk to live separately in +[`Ch-rter/contract`](https://github.com/Ch-rter/contract). + +## Prerequisites + +- **Node.js ≥ 20** and npm. +- **Go ≥ 1.26** — only if you want to run the indexer locally. +- **PostgreSQL** reachable via a connection string — again, only for the + indexer. +- **A Stellar wallet** supported by + [`stellar-wallets-kit`](https://github.com/Creit-Tech/Stellar-Wallets-Kit) + (Freighter, for example), on the network your config points at. You need this + only to sign transactions; browsing the read-only UI needs no wallet. + +## Install the workspace + +`packages/*` and `apps/*` are one npm workspace with a single root lockfile. +From the repository root: + +```bash +npm ci +``` + +The SDK (`packages/sdk`) is consumed as TypeScript source and transpiled by +Next.js. There is no separate SDK build step to run before starting the web app +— `npm ci` at the root is enough. + +## Run the indexer (optional for read-only UI) + +The indexer is a standalone Go module. It needs a Postgres database and the +deployed factory contract id. A throwaway local database using the credentials +from `indexer/.env.example`: + +```bash +docker run --name charter-pg -e POSTGRES_USER=charter \ + -e POSTGRES_PASSWORD=charter -e POSTGRES_DB=charter \ + -p 5432:5432 -d postgres:16 +``` + +Then configure and start it: + +```bash +cd indexer +cp .env.example .env # then fill in FACTORY_CONTRACT_ID +go run . +``` + +The database schema is applied on startup and the operation is idempotent, so +there is no separate migration step. The service ingests events in the +background and serves the REST API on `PORT` (default `8080`). + +If you only want to work on the UI against an already-running indexer, you can +skip this section and point the web app at that indexer's URL instead — see the +next step. + +## Run the web app + +```bash +cd apps/web +cp .env.example .env.local # then fill in the values +``` + +Point `NEXT_PUBLIC_INDEXER_API_URL` at your indexer — `http://localhost:8080` +for a local one, or a hosted indexer URL. Then, from the repository root: + +```bash +npm run dev:web +``` + +The app starts on . Until a factory contract is deployed +and configured you can leave `NEXT_PUBLIC_FACTORY_CONTRACT_ID` blank and browse +the read-only UI; deploying a treasury or raising a request needs the real +factory id and a funded wallet. + +Every variable in both `.env` files is explained on the [Environment +Variables](environment-variables.md) page. + +## Useful scripts + +Run from the repository root: + +| Command | Does | +| --- | --- | +| `npm run dev:web` | Start the web app in development. | +| `npm run build:web` | Production build of the web app. | +| `npm run build:sdk` | Type-check the SDK. | +| `npm run typecheck` | Type-check every workspace. | +| `npm run lint` | Lint every workspace. | + +For the indexer, from `indexer/`: `go vet ./...`, `go build ./...`, `go run .`. + +These are the same jobs CI runs on every pull request — "Web (lint · typecheck · +build)" and "Indexer (vet · build)" — so running them locally before you push +saves a round trip. diff --git a/docs/developer-guide/sdk-reference.md b/docs/developer-guide/sdk-reference.md new file mode 100644 index 0000000..fc0542b --- /dev/null +++ b/docs/developer-guide/sdk-reference.md @@ -0,0 +1,179 @@ +# SDK Reference + +`@charter/sdk` (`packages/sdk`) is the TypeScript layer for talking to the +Charter contracts. It is the only path in the app allowed to call +state-changing contract methods, so the whole simulate → prepare → sign → send → +poll sequence lives in one place. The web app calls these functions; it never +assembles a contract call itself. + +The SDK is wallet-agnostic. Writes take a `signXdr` callback, so `packages/sdk` +never depends on the wallet library — the web layer plugs one in. Signatures on +this page are copied from the source in `packages/sdk/src`. + +## Reads and writes + +- **Reads** are free simulations: no wallet, no signature, no fee. They return + decoded domain types. +- **Writes** take the caller's public key and a `signXdr` callback, and return + the confirmed transaction hash (the factory's `deployTreasury` also returns + the new treasury address). Under the hood each write simulates first — so a + contract error surfaces before the user is asked to sign — then prepares, + signs, submits, and polls to a definitive success or failure. + +### The `signXdr` callback + +Every write ends in a signature. Its type: + +```ts +type SignXdr = ( + xdrBase64: string, + options: { networkPassphrase: string }, +) => Promise; +``` + +It takes a base64 transaction envelope and returns the signed envelope. The web +layer implements this on top of the connected wallet; the SDK just calls it. + +### Amounts are `bigint` + +Token amounts (`cap`, `amount`) are `bigint` in the token's smallest unit — never +JavaScript numbers, never floats. The reference token has 7 decimals, so +`31.4159265` tokens is `314159265n`. Convert human input with `parseAmount` and +display with `formatAmount` (both in the web layer's `lib/format.ts`), which are +bigint-safe across the full `i128` range. + +## Factory client + +Imported as `import { factory } from '@charter/sdk'`. + +### Writes + +```ts +factory.initialize( + factoryId: string, + deployer: string, + wasmHash: string | Uint8Array, + signXdr: SignXdr, +): Promise + +factory.deployTreasury( + factoryId: string, + admin: string, + name: string, + approvers: string[], + threshold: number, + token: string, + signXdr: SignXdr, +): Promise +``` + +`initialize` registers the treasury wasm the factory deploys from; it is an +admin-only, run-once setup call, surfaced here for tooling. + +`deployTreasury` deploys a per-org treasury and returns both the transaction +hash and the new treasury address: + +```ts +interface DeployTreasuryResult { + hash: string; + treasuryAddress: string; +} +``` + +Note the argument order: the caller passes `admin` and the SDK uses it as the +account that authorizes and pays. This is the call the new-org form makes. +Because the treasury's own `initialize` runs as a sub-call that requires the +admin's authorization, the wallet is asked to sign twice — see [Getting +Started](../for-organization-admins/getting-started.md). + +### Reads + +```ts +factory.getOrg(factoryId: string, orgId: number): Promise +factory.getOrgCount(factoryId: string): Promise +factory.getOrgs(factoryId: string, start: number, limit: number): Promise +``` + +## Treasury client + +Imported as `import { treasury } from '@charter/sdk'`. + +### Admin — approvers and threshold + +```ts +treasury.addApprover(treasuryId, admin, approver, signXdr): Promise +treasury.removeApprover(treasuryId, admin, approver, signXdr): Promise +treasury.setThreshold(treasuryId, admin, threshold: number, signXdr): Promise +``` + +### Admin — categories + +```ts +treasury.createCategory(treasuryId, admin, name: string, cap: bigint, signXdr): Promise +treasury.updateCategoryCap(treasuryId, admin, categoryId: number, newCap: bigint, signXdr): Promise +treasury.setCategoryActive(treasuryId, admin, categoryId: number, active: boolean, signXdr): Promise +``` + +### Funds and requests + +```ts +treasury.deposit(treasuryId, from, amount: bigint, signXdr): Promise +treasury.submitRequest( + treasuryId, requester, categoryId: number, recipient: string, + amount: bigint, memo: string, signXdr, +): Promise +treasury.approveRequest(treasuryId, approver, requestId: number, signXdr): Promise +treasury.rejectRequest(treasuryId, approver, requestId: number, signXdr): Promise +treasury.cancelRequest(treasuryId, requester, requestId: number, signXdr): Promise +``` + +In each write, the second argument (`admin`, `from`, `requester`, or `approver`) +is both the address recorded on-chain for that action and the account that signs +and pays. `approveRequest` may execute the request in the same call: when the +approval meets the threshold, the contract transfers the funds and marks the +request Executed atomically. + +### Views — free reads, no signature + +```ts +treasury.getCategory(treasuryId, categoryId: number): Promise +treasury.getCategories(treasuryId): Promise +treasury.getRequest(treasuryId, requestId: number): Promise +treasury.getRequestsByCategory(treasuryId, categoryId: number): Promise +treasury.getBalance(treasuryId): Promise +treasury.getApprovers(treasuryId): Promise +treasury.getThreshold(treasuryId): Promise +``` + +`getBalance` returns a `bigint`; the two view collections decode into the +`Category` and `Request` types the contract defines (see the [Treasury +Contract](../smart-contracts/treasury.md) page for their fields). + +## Errors + +A contract failure surfaces as a typed error with a plain-English message, so +callers never handle a raw contract string: + +- `treasury.TreasuryCallError` — carries the numeric contract error `code` (when + one can be parsed from the RPC message) and a human-readable message. +- `factory.FactoryCallError` — the same, for factory calls. + +Both wrap the lower-level `ContractError` thrown by the RPC layer. The numeric +`code` is the contract's `#[contracterror]` discriminant, parsed from Soroban's +`Error(Contract, #N)` message. + +> **Known drift.** The SDK's own `TreasuryError` / `FactoryError` enums in +> `packages/sdk/src/types.ts` do not currently line up one-for-one with the +> contract's `errors.rs`. When you need the authoritative code-to-meaning +> mapping, use the contract source and the tables on the +> [treasury](../smart-contracts/treasury.md) and +> [factory](../smart-contracts/factory.md) pages, not the SDK enum. This is a +> tracked bug, not a documentation gap. + +## Reads for display come from the indexer, not the SDK + +The SDK's view functions read live contract state by simulation. The web app +uses them where it needs the current on-chain value (the treasury balance panel, +for instance). For lists and history — organizations, categories, requests — the +app reads the indexer's REST API instead, which is faster and queryable. That +API is documented on the [API Reference](api-reference.md) page. diff --git a/docs/for-approvers-and-requesters/approving-rejecting-and-cancelling.md b/docs/for-approvers-and-requesters/approving-rejecting-and-cancelling.md new file mode 100644 index 0000000..c0ddccc --- /dev/null +++ b/docs/for-approvers-and-requesters/approving-rejecting-and-cancelling.md @@ -0,0 +1,74 @@ +# Approving, Rejecting, and Cancelling + +Once a request is Pending, three actions can move it out of that state: +approval, rejection, and cancellation. They are not symmetric — they need +different people and different levels of agreement, on purpose. This page +covers who can do what, and what happens on-chain when they do. + +## Who can act + +On a request detail page (**Request #N**), the available actions depend on who +you are: + +- **Approvers who haven't yet signed** see **Approve** and **Reject**. +- **The requester** sees **Cancel**, while the request is Pending. +- If you are connected but are neither an approver nor the requester, the page + tells you: *"Only approvers and the requester can act on this request."* +- If no wallet is connected: *"Connect a wallet to approve, reject, or cancel + this request."* + +The page also gives you a contextual line about your own standing — for example +"You have approved this request," "Your signature is needed to move this request +forward," or "You raised this request. You can cancel it while it is pending." + +## Approving + +**Approve** records your signature on the request. Each approver can approve +once; a second attempt from the same approver is rejected by the contract +(`AlreadyApproved`). The **Approvals** section shows progress toward the +threshold and a roster marking each approver "Approved" or "Awaiting." + +When your approval is the one that meets the threshold, the request **executes +in the same transaction**. The contract transfers the amount to the recipient, +adds it to the category's spent total, and sets the request to **Executed**. +There is no separate payout step — the final approval and the disbursement are +one atomic action. If anything in that execution would fail, the whole approval +fails with it; the request does not end up half-done. + +### Why an executed request shows one approval + +Because the threshold-meeting approval executes the request, the contract emits +a `RequestExecuted` event for it rather than a `RequestApproved` event. The +indexer counts approvals from `RequestApproved` events, so an executed request +shows one fewer recorded approval than the number of people who actually signed. +A 2-of-2 request that executed displays a single recorded approval. This is a +read-model detail, not a lost signature — see the [API +Reference](../developer-guide/api-reference.md) page. + +## Rejecting + +**Reject** is available to any approver, and a single rejection is **terminal**: +the request moves straight to **Rejected** and cannot return to Pending. +Rejection deliberately needs no threshold. Authorizing money to move is the +high-coordination action and takes the full set of approvals; blocking a bad +disbursement should not need the same coordination — if any one approver sees a +problem, they can stop it alone. + +A rejected request cannot be re-opened or re-approved. If the disbursement is +still wanted, someone submits a new request. + +## Cancelling + +**Cancel** is for the **requester only**, and only while the request is Pending. +It lets the person who raised a request withdraw it before an approval decision +is forced. An approver cannot cancel someone else's request (the contract raises +`NotRequester`), and a request that has already executed, been rejected, or been +cancelled cannot be cancelled again (`RequestNotPending`). + +## The one-way rule + +All three transitions are final. A request leaves Pending exactly once — to +Executed, Rejected, or Cancelled — and never comes back. There is no editing a +Pending request either; to change an amount, recipient, or memo, cancel it and +submit a new one. The complete state machine is drawn on the [Request +Lifecycle](../protocol/request-lifecycle.md) page. diff --git a/docs/for-approvers-and-requesters/submitting-a-request.md b/docs/for-approvers-and-requesters/submitting-a-request.md new file mode 100644 index 0000000..c580aaf --- /dev/null +++ b/docs/for-approvers-and-requesters/submitting-a-request.md @@ -0,0 +1,62 @@ +# Submitting a Request + +A request is a proposal to move funds out of the treasury, drawn against one +budget category. Anyone with a connected wallet can submit one — you do not have +to be an admin or an approver to request money. The request stays **Pending** +until approvers reach the threshold, at which point it executes and pays out. +This page walks through submitting one. + +## Before you start + +- **Connect your wallet.** The request form needs a connected account to record + as the requester and to sign the transaction. +- **There must be an active category.** Requests draw against a category, and + only active ones are offered. If every category is paused or none exists yet, + the form tells you: *"There are no active categories to request against. Ask + an admin to create one or resume a paused category first."* + +## Filling in the request + +From the treasury dashboard, choose **New request**. The modal explains the +action: *"Propose a disbursement. It stays pending until approvers reach the +threshold."* Four fields: + +- **Category** — a dropdown of the active categories. Each shows how much has + been spent of its cap, so you can see the room left before you draw against + it. +- **Recipient** — the Stellar account or contract that receives the funds. + Charter validates this as either a `G…` account address or a `C…` contract + address; anything else is rejected before you can submit. +- **Amount** — how much to disburse, in token units (e.g. `1,000.00`). This is + drawn against the category's remaining cap. You enter human-readable units and + Charter converts to the token's smallest unit; the reference token has 7 + decimals, so `1,000.00` becomes `10000000000` on-chain. +- **Memo** — a short, optional note (e.g. `Q3 contractor invoice`). It is capped + at **64 bytes**. Leave it empty and the request records no memo. + +Submit with **Submit request** ("Submitting…" while it confirms). You sign once. + +## What the cap check means for you + +The category cap is enforced **at submission**, not later. If your amount +exceeds the category's remaining room (`cap − spent`), the contract rejects the +request outright — it is never created in a state that would overspend. If that +happens, either request a smaller amount or ask the admin to raise the +category's cap. This is deliberate: a request that exists is always a request +the category could actually pay. + +## After you submit + +Your request appears in the treasury's request list as **Pending**, with your +wallet recorded as the requester. From here: + +- Approvers decide. When approvals reach the threshold, the request **executes + in the same step** — the funds move to the recipient and the category's spent + total goes up. There is no separate "pay out" action after the final approval. +- A single approver can **reject** it, which is terminal. +- You, as the requester, can **cancel** it while it is still Pending. + +Those actions are covered on [Approving, Rejecting, and +Cancelling](approving-rejecting-and-cancelling.md). The full set of states a +request can move through is on the [Request +Lifecycle](../protocol/request-lifecycle.md) page. diff --git a/docs/for-organization-admins/getting-started.md b/docs/for-organization-admins/getting-started.md new file mode 100644 index 0000000..771ca52 --- /dev/null +++ b/docs/for-organization-admins/getting-started.md @@ -0,0 +1,73 @@ +# Getting Started + +This section is for the person who sets up an organization on Charter: you +deploy the treasury, define who can approve spending, and set the rule for how +many approvals a disbursement needs. You do not need to write code. You do need +a Stellar wallet and some testnet funds. + +## What you need first + +- **A Stellar wallet** that Charter can connect to through the wallet dialog — + Freighter is the common choice on testnet. You connect it with the **Connect + wallet** button in the top right. +- **Testnet XLM** in that wallet to pay transaction fees. On testnet you can + fund an account from the Stellar friendbot for free. +- **A token address** for the asset the treasury will hold and pay out. This is + a Stellar Asset Contract (SAC) address — a `C…` contract address, or the `G…` + issuer form the form also accepts. The treasury holds and disburses this one + token. +- **The addresses of your approvers.** These are the people who sign off on + disbursements. Have their `G…` addresses ready. Your own connected wallet is + added as the first approver automatically; you can add more. + +## Deploying your treasury + +From the home page, choose **New organization**. If your wallet is not +connected yet, the page tells you so — "Connect your wallet to create an +organization" — and you connect first. + +The deploy form (the page header reads: *"Deploy a treasury contract. You'll be +its admin, and the approver set you define governs every disbursement."*) has +four things to fill in: + +- **Organization name** — a plain label, e.g. `Acme Treasury`. This is stored + on-chain in the factory's org record and shown in the public directory. +- **Token address** — the SAC token address this treasury holds and disburses. + Enter the `C…` (or `G…`) address of your asset. +- **Approvers** — the addresses that can approve or reject disbursement + requests. Your connected wallet is pre-filled as the first approver. Use **Add + approver** to add more. These are the only accounts that will be able to + approve or reject; choose them deliberately, because changing the set later is + itself an admin action on-chain. +- **Approval threshold** — how many approvers must sign off before a request + executes. The form phrases it as "of N approvers," where N is the number of + approver rows you have filled. A threshold of 2 with 2 approvers means both + must approve. + +Submit with **Deploy treasury**. The button shows "Deploying…" while the +transaction is in flight. + +### This transaction needs two signatures + +Deploying goes through the factory contract, and it asks your wallet to sign +**twice** in one flow. That is expected, not a bug. The factory authorizes you +as the deployer, and the new treasury's own `initialize` step requires the +admin — you — to authorize it as a sub-call. Approve both prompts. If you only +approve the first, the deploy will not complete. + +When it finishes, Charter sends you to your new organization's page at +`/org/`. That address is your treasury; bookmark it. + +## What you are now + +The wallet you deployed with is the treasury's **admin**. Being admin is what +lets you create budget categories, adjust caps, pause and resume categories, and +change the approver list and threshold. Approvers (including you, since you were +added as the first approver) can approve and reject requests. Anyone at all can +read the treasury's state — balances, categories, and every request — because it +all lives on-chain and the indexer serves it publicly. + +The next page, [Setting Up Categories and +Approvers](setting-up-categories-and-approvers.md), covers the admin actions you +will take right after deploying: creating the categories money is spent against, +and adjusting the approver set. diff --git a/docs/for-organization-admins/reading-the-public-ledger.md b/docs/for-organization-admins/reading-the-public-ledger.md new file mode 100644 index 0000000..08ce728 --- /dev/null +++ b/docs/for-organization-admins/reading-the-public-ledger.md @@ -0,0 +1,79 @@ +# Reading the Public Ledger + +Everything a Charter treasury does is public. The contracts store their state +on-chain, the indexer mirrors it, and the web app reads it without a wallet. +Anyone can audit an organization's spending: its categories, its caps, its +balance, and every request with the addresses that approved it. This page is +about reading that record — no wallet needed, nothing to sign. + +## The organization directory + +The home page lists every indexed organization. An orientation line at the top +states what Charter is: *"Charter is a treasury operations layer for Stellar +organizations. Set budget caps, route disbursements through approvals, and +settle on-chain."* Below it, under the **Organizations** heading ("Stellar +treasuries indexed on-chain"), each row shows the org name, the admin address +(truncated), the treasury address, and the ledger it was created at. Selecting a +row opens that treasury's dashboard. If nothing is indexed yet, the list says +"No organizations yet." + +## The treasury dashboard + +Open a treasury and you see its whole state at a glance: + +- **Treasury balance** — a panel labeled *"Live from the treasury contract"* + showing the current token balance. This reads the contract directly, so it is + the live number, not a cached one. +- **Summary chips** — how many categories exist, how many requests are pending, + and the approval rule ("N of M approvals required"). +- **Budget categories** — each category's name, its spent-of-cap figure, a + percentage bar, and a **Paused** badge on any deactivated category. An + allocation bar shows how the categories divide the budget. A category over + ~90% of its cap carries a near-cap warning. +- **Requests** — the list of disbursement requests, each with its status. + +You do not need to be the admin or an approver to see any of this. The +admin-only and approver-only buttons (create category, new request, approve) +simply don't appear when you are not that party — but the full record is +visible to everyone. + +## Reading a request + +Open a request to see its detail: the header **Request #N**, a status badge +(Pending, Executed, Rejected, or Cancelled), which category it draws against, +and the disbursement amount. Below that: + +- **Recipient** — the account or contract that receives the funds. +- **Requested by** — the address that raised it. +- **Memo** — the note attached at submission, or "None" if there wasn't one. +- **Created** — the ledger the request was submitted at. +- **Approvals** — a progress indicator toward the threshold and a roster of + approvers, each marked "Approved" or "Awaiting," with "Requester" and "Former + approver" badges where they apply. + +An invalid request id shows "Request not found." + +## One thing to know when auditing approvals + +For a request that **executed**, the on-chain record — and therefore the ledger +you are reading — shows exactly one approval, even if more approvers signed. This +is not data loss. The approval that meets the threshold executes the request in +the same step, and the contract emits a `RequestExecuted` event for it rather +than a `RequestApproved` event. The indexer counts approvals from +`RequestApproved` events only, so the final, decisive approval isn't counted as +an approval. A 2-of-2 request that executed will show one recorded approval plus +the execution. + +If you are reconciling "who approved this," read an **executed** request as: the +listed approver(s), plus whoever triggered execution. The mechanism is described +from the contract side on the [Treasury +Contract](../smart-contracts/treasury.md) page and from the API side on the [API +Reference](../developer-guide/api-reference.md) page. + +## Reading it without the web app + +The same data is available directly from the indexer's REST API — organizations, +categories, requests, and single requests — with no authentication. If you want +to pull the ledger into your own tooling or a spreadsheet, the endpoints and +their exact responses are documented on the [API +Reference](../developer-guide/api-reference.md) page. diff --git a/docs/for-organization-admins/setting-up-categories-and-approvers.md b/docs/for-organization-admins/setting-up-categories-and-approvers.md new file mode 100644 index 0000000..0292f2b --- /dev/null +++ b/docs/for-organization-admins/setting-up-categories-and-approvers.md @@ -0,0 +1,86 @@ +# Setting Up Categories and Approvers + +Right after you deploy, your treasury has an approver set and a threshold but no +budget categories. Money is spent against categories, so nothing can be +requested until you create at least one. This page covers the admin actions you +take from the treasury dashboard. All of them require the admin wallet to sign; +if you are not connected as the admin, the buttons that trigger them do not +appear. + +## Creating a category + +On the treasury dashboard, under the **Budget categories** heading, choose **New +category**. The modal explains itself: *"Create a budget category. Requests draw +against its cap."* Two fields: + +- **Name** — what the category is, e.g. `Engineering`. The name is set once at + creation and cannot be changed afterward. Choose it carefully. +- **Spending cap** — the maximum this category can disburse, in whole token + units (e.g. `10,000.00`). You enter human-readable units; Charter converts to + the token's smallest unit for you (the reference token has 7 decimals). The + cap must be greater than zero. + +Submit with **Create category**. The button reads "Creating…" while the +transaction confirms. + +### What the cap means + +A category's cap is a **lifetime total**, not a monthly or quarterly allowance. +The category tracks a `spent` figure that only ever goes up; `cap − spent` is +what remains available to request. When the running total approaches the cap you +raise the cap — spending does not reset on any schedule. This is covered in more +depth on the [Category Mechanics](../protocol/category-mechanics.md) page. + +## Adjusting a cap + +To change a cap, use the inline **Edit** action on a category row. The modal +title becomes **Edit category cap** and tells you: *"Adjust the spending cap. The +category name cannot be changed."* The name field is locked; only the cap is +editable. + +You can raise a cap to any higher value. You can lower it only down to what the +category has already spent — never below. The contract rejects a cap below the +current `spent`, because `spent` records money that already left the treasury and +the cap must not contradict that record. Save with **Save cap** ("Saving…" while +in flight). + +## Pausing and resuming a category + +Each category row has an inline **Pause** action (and **Resume** once paused). A +paused category shows a **Paused** badge and stops accepting new requests — the +submit-request form will not offer it. Everything already requested against it +is unaffected, and its full history stays queryable. There is no delete: a +category that ever funded a request stays on the books so past spending remains +attributable. Pausing is how you retire a category without erasing its record. + +## Reading the category display + +The dashboard shows an allocation bar across all categories and, per row, the +category name, a spent-of-cap figure, and a percentage progress bar. When a +category crosses about 90% of its cap it gets a near-cap warning — your cue to +either raise the cap or let it fill and stop. Chips at the top of the dashboard +summarize the treasury: number of categories, number of pending requests, and +the approvals-required rule shown as "N of M." + +## Changing approvers and the threshold + +The approver list and threshold are set at deploy time, and both are admin +controls on-chain (`add_approver`, `remove_approver`, `set_threshold` on the +treasury contract — see the [Treasury Contract](../smart-contracts/treasury.md) +page). Two rules the contract enforces, worth knowing before you change +anything: + +- You cannot set a threshold of zero, and you cannot set it higher than the + number of approvers. +- You cannot remove an approver if doing so would leave fewer approvers than the + current threshold. If you need to drop below that count, lower the threshold + first, then remove the approver. This exists so a treasury can never be + stranded needing more approvals than it has approvers. + +The threshold is a **count**, not a percentage: "2 of 3" means any two of the +three approvers. See [Approval Threshold +Model](../protocol/approval-threshold-model.md) for the reasoning. + +With categories created and approvers set, requesters can start submitting +disbursements. That flow is covered in [Submitting a +Request](../for-approvers-and-requesters/submitting-a-request.md). diff --git a/docs/introduction/how-it-works.md b/docs/introduction/how-it-works.md new file mode 100644 index 0000000..9c03008 --- /dev/null +++ b/docs/introduction/how-it-works.md @@ -0,0 +1,43 @@ +# How It Works + +Charter moves money through six steps, from deploying a treasury to a category +recording what it has spent. The numbers below come from Charter's own +verified testnet runs. + +1. **An organization deploys a treasury instance through the factory** + (`CCUQBFFRGR4RUWHKLWSRWKBL3WORHNTHFLTKMHTNUZL4T5733ODN5WD4` on testnet), + naming an admin, a list of approvers, an approval threshold, and the token + it will hold. + +2. **The admin creates budget categories** — a name and a spend cap each. + Charter's own verified testnet run created a category named "Ops Budget" + with a cap of 1,000,000,000 units, later raised to 2,000,000,000. + +3. **Anyone deposits funds into the treasury** against its bound token. + +4. **A contributor submits a disbursement request against a category** — who + gets paid, how much, and why. Charter's test run submitted a request for + 314,159,265 units against the "Payroll" category. + +5. **Approvers review and either approve or reject.** Once enough approvals are + collected to meet the threshold — Charter's reference deployment used a + 2-of-2 threshold — the transfer executes automatically in the same + transaction as the final approval. No separate "release funds" step exists; + crossing the threshold *is* the release. + +6. **The category's spent total increases** by the request amount, permanently + capping what else that category can pay out until the admin raises the cap. + +## Amounts are in the token's smallest unit + +Every figure above is in the token's smallest unit, not a decimal amount. The +token Charter's test runs used has 7 decimals, so 314,159,265 units is +31.4159265 tokens. Contract calls and the indexer's API always work in these +integer base units; the web dashboard is what converts them to and from the +decimal amounts a person reads. This keeps money exact — it is an integer end +to end and never a floating-point value. + +Each step has a page that covers it in full: category caps and the raise/lower +rules under [Category Mechanics](../protocol/category-mechanics.md), the +approve/reject/cancel rules under [Request Lifecycle](../protocol/request-lifecycle.md), +and the count-based threshold under [Approval Threshold Model](../protocol/approval-threshold-model.md). diff --git a/docs/introduction/the-problem.md b/docs/introduction/the-problem.md new file mode 100644 index 0000000..54cb4f5 --- /dev/null +++ b/docs/introduction/the-problem.md @@ -0,0 +1,35 @@ +# The Problem + +DAO and grant-funded treasuries operate at real scale with limited built-in +accountability tooling. + +- Uniswap's DAO treasury holds roughly **$4.8B**, predominantly in UNI + tokens, with the Uniswap Foundation handling a roughly **$45M** annual + operating budget and a roughly **$40M** annual grant program. +- Optimism's RetroPGF rounds have distributed over **$200M** across more than + **2,000 projects** since 2022. +- Arbitrum's STIP and LTIPP programs have moved **$40M–$200M** per funding + round. + +Each of these programs relies on published quarterly reports, treasury +committees, or foundation-level bookkeeping to demonstrate accountability +after the fact — not a protocol that enforces budget limits or approval +thresholds at the point money moves. + +## A multisig is not a budget + +A bare multisig wallet solves who can sign, not what a signature is allowed to +authorize. It has no concept of a budget category, no cap on how much a given +category can spend, and no way for an outside observer to check "was this +transfer actually within policy" without trusting a manually published report. + +Charter puts that policy on-chain, so the constraint is enforced by the +contract at the moment funds move, not reconstructed afterward from a +spreadsheet. A request that would push a category past its cap does not get a +warning in a later audit — it is rejected by the treasury when it is +submitted. And because every category, cap, request, and approval is stored +on-chain and publicly readable, verifying that spending stayed within policy +means reading the ledger, not trusting a summary of it. + +The mechanics of how that enforcement works are covered in [How It Works](how-it-works.md) +and, in full, under [Protocol](../protocol/request-lifecycle.md). diff --git a/docs/protocol/approval-threshold-model.md b/docs/protocol/approval-threshold-model.md new file mode 100644 index 0000000..c144b57 --- /dev/null +++ b/docs/protocol/approval-threshold-model.md @@ -0,0 +1,29 @@ +# Approval Threshold Model + +The threshold is a count of approvals required, not a percentage — a treasury +with 5 approvers and a threshold of 2 needs any 2 of those 5 to agree, not 40% +of some larger pool. Charter's reference deployment used a 2-of-2 threshold: +both configured approvers had to sign off before execution. + +The admin can add or remove approvers and change the threshold at any time, +with one constraint enforced on-chain: removing an approver is rejected if it +would drop the approver count below the current threshold. A treasury can never +be left in a state where the required number of approvals is mathematically +unreachable. + +## Approve, reject, and cancel are not symmetric + +The three ways a pending request can leave the Pending state need different +levels of agreement, on purpose: + +- **Approval** requires the full threshold. Authorizing money to move is the + high-coordination action, so it takes the agreed-upon number of approvers. +- **Rejection** requires a single approver. Blocking a bad disbursement should + not need the same coordination as authorizing one — if any one approver sees + a problem, they can stop it. +- **Cancellation** is requester-only, and only while the request is Pending. + The person who raised a request can withdraw it before any approval decision + forces the issue. + +This is the reasoning behind the transitions drawn on the +[Request Lifecycle](request-lifecycle.md) page. diff --git a/docs/protocol/category-mechanics.md b/docs/protocol/category-mechanics.md new file mode 100644 index 0000000..e5dad74 --- /dev/null +++ b/docs/protocol/category-mechanics.md @@ -0,0 +1,30 @@ +# Category Mechanics + +A category holds a `name`, a `cap`, a `spent` total, and an `active` flag. +`cap - spent` at any moment is what remains available for new requests. + +## A worked example: "Ops Budget" + +Walk through Charter's own test category, "Ops Budget": created with a cap of +1,000,000,000. The admin later raised the cap to 2,000,000,000 via +`update_category_cap` — always legal, since a cap can only be raised or lowered +down to (never below) the current `spent` value. If a category has already +spent 314,159,265, the admin cannot set its cap below that number; the contract +rejects it. + +## Deactivating a category + +Deactivating a category (`set_category_active` with `active: false`) blocks new +`submit_request` calls against it but does not affect requests already in +flight. There is deliberately no delete function — a category that funded past +requests must stay queryable so historical spend stays attributable, even after +the org stops using it for new requests. + +## Caps are lifetime totals + +A category's `spent` is cumulative and never resets. The cap is not a +per-month or per-quarter allowance; it is the total this category may ever pay +out at its current cap setting. When a category is close to its cap, the admin +raises the cap to make more room — the spent history stays intact underneath. +This is why lowering a cap below `spent` is rejected: `spent` is a record of +money that already moved, and the cap can never be set to contradict it. diff --git a/docs/protocol/request-lifecycle.md b/docs/protocol/request-lifecycle.md new file mode 100644 index 0000000..f7a7d71 --- /dev/null +++ b/docs/protocol/request-lifecycle.md @@ -0,0 +1,52 @@ +# Request Lifecycle + +Every disbursement is a request, and every request moves through a small state +machine. A request starts Pending and ends in exactly one of three terminal +states: Executed, Rejected, or Cancelled. It cannot move backward. + +## Pending + +The state every request starts in on `submit_request`. Any approver in the +treasury's approver list can approve or reject a pending request; the original +requester can cancel their own pending request. A request cannot move directly +to Executed without passing through approvals being collected one at a time. + +## Executed + +Reached only when `approve_request` pushes the approval count to meet or exceed +the treasury's threshold. Execution and the final approval happen atomically — +there's no window where a request is "fully approved but not yet paid." The +recipient receives the funds, and the category's `spent` field increases by the +exact request amount, in the same transaction. + +## Rejected + +A terminal state reached by any single approver calling `reject_request` on a +pending request. Unlike approval, rejection needs no threshold — one approver's +rejection is final, by design, since blocking a bad disbursement should not +require the same coordination as authorizing one. + +## Cancelled + +A terminal state reachable only by the original requester, only while the +request is still Pending. Once any approval has been recorded or the request +has moved to any other state, cancellation is no longer available. + +## No return to Pending + +A request cannot re-enter Pending from any terminal state. A new request must +be submitted. + +``` + approve_request (meets threshold) + ┌──────────────────────────────────────────► Executed + │ +Pending ─┼──────── reject_request (any 1 approver) ──► Rejected + │ + └──────── cancel_request (requester only) ───► Cancelled +``` + +The asymmetry between these transitions — approval needs the full threshold, +rejection needs one approver, cancellation is requester-only — is deliberate. +The reasoning is on the [Approval Threshold Model](approval-threshold-model.md) +page. diff --git a/docs/smart-contracts/factory.md b/docs/smart-contracts/factory.md new file mode 100644 index 0000000..74dcc29 --- /dev/null +++ b/docs/smart-contracts/factory.md @@ -0,0 +1,88 @@ +# Factory Contract + +The factory deploys treasuries and keeps the on-chain registry of them. It is +deployed once and initialized once; after that, every treasury an organization +creates goes through it. Every function below is taken from +`contracts/factory/src/lib.rs`. + +The factory stores three things in instance storage — the deployer address, the +treasury wasm hash, and the running org count — and one `OrgRecord` per org in +persistent storage. + +## `initialize(deployer, wasm_hash)` + +Sets the address authorized to deploy treasuries and the hash of the treasury +wasm the factory will deploy. Requires `deployer` to sign. + +- Panics `AlreadyInitialized` if called a second time. + +## `deploy_treasury(name, admin, approvers, threshold, token) -> u32` + +Deploys a new treasury and initializes it in the same transaction. Returns the +new org id. + +The factory assigns the next org id (starting at 1), uses that id as a 32-byte +deploy salt so the treasury's address is deterministic, deploys the stored +treasury wasm, then calls the new treasury's `initialize` with the supplied +admin, approvers, threshold, and token. It records an `OrgRecord`, increments +the org count, and emits `TreasuryDeployed`. + +**This call needs two signatures.** The stored deployer must sign — it is +authorized against the factory. And the `admin` must also sign, because the +treasury's own `initialize` calls `admin.require_auth()` as a sub-call; +requiring the admin's signature at the top ties that authorization to the root +transaction so the sub-call succeeds. + +- Panics `NotInitialized` if the factory has not been initialized. +- The treasury's `initialize` runs as part of this call, so its own panics + apply too — for example `InvalidThreshold` if `threshold == 0` or + `threshold > approvers.len()`. + +## `get_org(org_id) -> OrgRecord` + +Returns the record for one org. + +- Panics `OrgNotFound` if the org id does not exist. + +## `get_org_count() -> u32` + +Returns the number of orgs deployed so far. Org ids run from 1 to this count. + +## `get_orgs(start, limit) -> Vec` + +Returns a page of org records beginning at `start` (inclusive, clamped to a +minimum of 1). `limit` is capped at 50 — requesting more returns 50. + +## Types + +```rust +struct OrgRecord { + name: String, + treasury: Address, + admin: Address, + created_ledger: u32, +} +``` + +## Errors + +| Error | # | Meaning | +| --- | --- | --- | +| `NotInitialized` | 1 | Called before `initialize`. | +| `AlreadyInitialized` | 2 | `initialize` called twice. | +| `NotDeployer` | 3 | Reserved for deployer-authorization failures. | +| `OrgNotFound` | 4 | Requested org id does not exist. | + +The discriminants here differ from the treasury's — the factory's +`NotInitialized` is 1 and `AlreadyInitialized` is 2, the reverse of the +treasury enum. When decoding a contract error, check which contract raised it. + +## Events + +| Event | Topic | Data | +| --- | --- | --- | +| `TreasuryDeployed` | `org_id` | `name`, `treasury`, `admin` | + +The indexer reads `TreasuryDeployed` to populate its list of organizations, +which is what the `/orgs` endpoint on the +[API Reference](../developer-guide/api-reference.md) page returns. diff --git a/docs/smart-contracts/overview.md b/docs/smart-contracts/overview.md new file mode 100644 index 0000000..2a8695f --- /dev/null +++ b/docs/smart-contracts/overview.md @@ -0,0 +1,65 @@ +# Smart Contracts Overview + +Charter is two Soroban contracts. The **factory** deploys and tracks treasuries; +each **treasury** holds one organization's policy and funds. This page explains +how they fit together and where the source lives. The per-function reference is +on the [treasury](treasury.md) and [factory](factory.md) pages. + +## Two contracts, two jobs + +The **factory** is deployed once. It stores the hash of the treasury wasm, the +address authorized to deploy (the deployer), and a running count of orgs. When +someone deploys a treasury through it, the factory assigns the next org id, +uses that id as the deploy salt so the treasury address is deterministic, +initializes the new treasury in the same transaction, and records an +`OrgRecord` for it. It is the on-chain registry: given an org id, it returns +that org's name, treasury address, admin, and the ledger it was created at. + +A **treasury** is deployed once per organization. It holds that org's approver +list, approval threshold, budget categories, and the bound token's balance. All +of the money mechanics — categories, caps, requests, approvals, execution — +live here. One treasury never reads or writes another's state; the factory is +the only thing that knows about all of them. + +## Deployed addresses (testnet) + +Both contracts run on the Stellar testnet, network passphrase +`Test SDF Network ; September 2015`. + +| Contract | Address | +| --- | --- | +| Factory | `CCUQBFFRGR4RUWHKLWSRWKBL3WORHNTHFLTKMHTNUZL4T5733ODN5WD4` | +| Reference treasury | `CAH4PUADD2X3K52TKETWTIL4GHPZT55LWUEVVOSH6B3D3KA2ZH7HQGTT` | + +The factory was initialized with the treasury wasm hash +`e6ee93a93dd18927abab8dc1c4f95ec820da020310b2b2a45a0588b91581df8a`. The +reference treasury runs wasm hash +`b72f664802f395192375b4fca2e0930cff6f994a8053ca568d4f96eb0032ba6c`. + +## Where the code is + +The contracts live in the `charter-contract` repository, written in Rust +against the Soroban SDK: + +- `contracts/treasury/src/lib.rs` — the treasury contract functions. +- `contracts/treasury/src/types.rs` — `Category`, `Request`, `RequestStatus`. +- `contracts/treasury/src/errors.rs` — the treasury error enum. +- `contracts/treasury/src/events.rs` — the events the treasury emits. +- `contracts/factory/src/lib.rs` — the factory contract functions. +- `contracts/factory/src/types.rs` — `OrgRecord` and storage keys. +- `contracts/factory/src/errors.rs` — the factory error enum. +- `contracts/factory/src/events.rs` — the `TreasuryDeployed` event. + +The suite runs 47 treasury tests and 11 factory tests, 58 in total. + +## How the indexer sees the contracts + +The contracts emit events on every state change — `CategoryCreated`, +`RequestSubmitted`, `RequestApproved`, `RequestExecuted`, and so on. The +[indexer](../developer-guide/api-reference.md) polls Soroban for these events, +writes them to Postgres, and serves them over a read-only REST API. The +contracts are the source of truth; the indexer is a queryable mirror of the +events they emit. One consequence of building from events shows up on the API +Reference page: a request that executes on its threshold-meeting approval emits +`RequestExecuted` rather than `RequestApproved`, so the indexer records one +fewer approval than the number of approvers who actually signed. diff --git a/docs/smart-contracts/treasury.md b/docs/smart-contracts/treasury.md new file mode 100644 index 0000000..163d031 --- /dev/null +++ b/docs/smart-contracts/treasury.md @@ -0,0 +1,210 @@ +# Treasury Contract + +One treasury holds a single organization's approver list, approval threshold, +budget categories, and the balance of one bound token. Every function below is +taken from `contracts/treasury/src/lib.rs`. Amounts are `i128` in the token's +smallest unit; the reference token uses 7 decimals, so 314,159,265 means +31.4159265 tokens. + +Functions that change state require the relevant party to sign. Admin-only +functions call an internal `require_admin` check that panics `NotInitialized` +if the contract was never initialized, then `NotAdmin` if the caller is not the +stored admin. + +## Setup + +### `initialize(admin, approvers, threshold, token)` + +Sets the treasury's admin, approver list, approval threshold, and bound token. +Requires `admin` to sign. + +- Panics `AlreadyInitialized` if called a second time. +- Panics `InvalidThreshold` if `threshold == 0` or `threshold > approvers.len()`. + +The factory calls this automatically inside `deploy_treasury`, so a treasury +deployed through the factory is already initialized. + +## Approvers and threshold + +### `add_approver(admin, approver)` + +Admin adds an approver. If the address is already an approver, the call is a +no-op rather than an error. + +### `remove_approver(admin, approver)` + +Admin removes an approver. + +- Panics `InvalidThreshold` if removing the approver would leave fewer + approvers than the current threshold. You cannot strand a treasury in a state + where its threshold can never be met — lower the threshold first, then remove + the approver. + +### `set_threshold(admin, threshold)` + +Admin changes the number of approvals required. + +- Panics `InvalidThreshold` if `threshold < 1` or `threshold > approvers.len()`. + +## Categories + +### `create_category(admin, name, cap) -> u32` + +Admin creates a budget category with a name and a spend cap. Returns the new +category id. + +- Panics `InvalidAmount` if `cap <= 0`. + +### `update_category_cap(admin, category_id, new_cap)` + +Admin changes a category's cap. + +- Panics `InvalidAmount` if the category does not exist, or if `new_cap` is + below the category's current `spent`. A cap can be raised freely and lowered + only down to what has already been spent — never below it. + +### `set_category_active(admin, category_id, active)` + +Admin activates or deactivates a category. A deactivated category rejects new +requests but keeps its history; there is no delete. + +- Panics `InvalidAmount` if the category does not exist. + +## Funds + +### `deposit(from, amount)` + +Moves `amount` of the bound token from `from` into the treasury's balance. +Requires `from` to sign. + +- Panics `InvalidAmount` if `amount <= 0`. + +## Requests + +### `submit_request(requester, category_id, recipient, amount, memo) -> u32` + +Requester submits a disbursement request against a category. Returns the new +request id. Requires `requester` to sign. + +- Panics `InvalidAmount` if the category does not exist. +- Panics `CategoryInactive` if the category is deactivated. +- Panics `InvalidAmount` if `amount <= 0`, or if the request amount exceeds the + category's remaining room (`cap - spent < amount`). The cap is enforced here, + at submission — not caught later in an audit. + +### `approve_request(approver, request_id)` + +An approver signs off on a pending request. Requires `approver` to sign. + +- Panics `NotApprover` if the caller is not in the approver list. +- Panics `RequestNotPending` if the request is not Pending. +- Panics `AlreadyApproved` if this approver already approved it. + +When the recorded approvals reach the threshold, the contract executes the +request in the same call: it transfers `amount` to the recipient, adds `amount` +to the category's `spent`, and sets the request to Executed. Execution and the +final approval are one atomic step. + +### `reject_request(approver, request_id)` + +An approver rejects a pending request. Requires `approver` to sign. A single +rejection is terminal — rejection needs no threshold. + +- Panics `NotApprover` if the caller is not in the approver list. +- Panics `RequestNotPending` if the request is not Pending. + +### `cancel_request(requester, request_id)` + +The original requester withdraws their own pending request. Requires +`requester` to sign. + +- Panics `RequestNotPending` if the request is not Pending. +- Panics `NotRequester` if the caller is not the address that submitted it. + +## Views + +These read state and take no signature. + +| Function | Returns | Notes | +| --- | --- | --- | +| `get_category(category_id)` | `Category` | Panics `InvalidAmount` if missing. | +| `get_categories()` | `Vec` | All categories. | +| `get_request(request_id)` | `Request` | Panics `RequestNotPending` if missing. | +| `get_requests_by_category(category_id)` | `Vec` | | +| `get_balance()` | `i128` | Treasury's token balance. | +| `get_approvers()` | `Vec
` | | +| `get_threshold()` | `u32` | | + +## Types + +```rust +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
, + status: RequestStatus, + created_ledger: u32, +} +``` + +## Errors + +The treasury error enum, with its on-chain discriminants: + +| Error | # | Meaning | +| --- | --- | --- | +| `AlreadyInitialized` | 1 | `initialize` called twice. | +| `NotInitialized` | 2 | Called before `initialize`. | +| `NotAdmin` | 3 | Caller is not the admin. | +| `NotApprover` | 4 | Caller is not an approver. | +| `CategoryInactive` | 5 | Request against a deactivated category. | +| `CapExceeded` | 6 | Reserved for cap-overflow conditions. | +| `RequestNotPending` | 7 | Action on a non-pending (or missing) request. | +| `InvalidThreshold` | 8 | Threshold zero, above approver count, or would strand approvers. | +| `AlreadyApproved` | 9 | Approver approved the same request twice. | +| `NotRequester` | 10 | Cancel attempted by someone other than the requester. | +| `InvalidAmount` | 11 | Non-positive amount/cap, missing category, or cap below spent. | + +Note that the SDK's `TreasuryError` enum in `packages/sdk/src/types.ts` does not +currently match this list one-for-one. When you need the authoritative mapping, +use `errors.rs` and this table, not the SDK enum. + +## Events + +The treasury emits an event on every state change. The indexer reads these: + +| Event | Topic | 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` | — | + +`RequestApproved` and `RequestExecuted` are separate events. An approval that +meets the threshold emits `RequestExecuted`, not `RequestApproved` — which is +why the indexer's approval count for an executed request is one lower than the +number who signed. This is described on the +[API Reference](../developer-guide/api-reference.md) page.