# CLC Software
> Technical docs for the Cosmo-Local Credit stack on Gnosis Chain.
## Addresses
Gnosis mainnet, chain ID `100`. Explorer: [Gnosisscan](https://gnosisscan.io).
### Registry
[`0xaC95B1A09f2dbaf4542e3Ba8c4Cce0234895D389`](https://gnosisscan.io/address/0xaC95B1A09f2dbaf4542e3Ba8c4Cce0234895D389)
| Identifier | Proxy | Implementation |
| -------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| `AccountIndex` | [`0xB1Ec3942dc9258de62056Febb0dc544D9EB1ED1a`](https://gnosisscan.io/address/0xB1Ec3942dc9258de62056Febb0dc544D9EB1ED1a) | AccountsIndex [`0x3cA9AB…BeE`](https://gnosisscan.io/address/0x3cA9AB9b8628b43f1A1c53f29e969BA84Ef88BeE) |
| `TokenIndex` | [`0x088352Bf610BFF30BBa39F02fa8EAD0F676d2ac0`](https://gnosisscan.io/address/0x088352Bf610BFF30BBa39F02fa8EAD0F676d2ac0) | TokenUniqueSymbolIndex [`0x0CEB18…2EF`](https://gnosisscan.io/address/0x0CEB18BA6562D3227717D98c044A5849bE8362EF) |
| `PoolIndex` | [`0x4cbbd3aC98422774f33a0069AC7A9F8255cEFC69`](https://gnosisscan.io/address/0x4cbbd3aC98422774f33a0069AC7A9F8255cEFC69) | same TokenUniqueSymbolIndex impl |
`PoolIndex` is a second `TokenUniqueSymbolIndex` (factory salt tag `poolindex`, so it does not collide with TokenIndex).
Owner / proxy admin: [`0x33a573149db22e759fB9A38Bcc461C12855c2645`](https://gnosisscan.io/address/0x33a573149db22e759fB9A38Bcc461C12855c2645). Registry deploy: block `48027888`.
```bash
REG=0xaC95B1A09f2dbaf4542e3Ba8c4Cce0234895D389
RPC=https://gnosis-rpc.publicnode.com
for id in AccountIndex TokenIndex PoolIndex; do
printf '%-16s %s\n' "$id" \
"$(cast call $REG 'addressOf(bytes32)(address)' \
"$(cast format-bytes32-string $id)" --rpc-url $RPC)"
done
```
### Relayer
| | Address |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Calibur | [`0x48A910C8e9FF0b14051b78d0c96dB069E57f0729`](https://gnosisscan.io/address/0x48A910C8e9FF0b14051b78d0c96dB069E57f0729) |
| clc-core signer (AccountIndex writer) | `0xBfE4784152A35610aF0Dcb726487F5d89118BbeF` |
Calibur is the EIP-7702 delegation target, not a proxy.
### Implementations (v1.0.0)
solc `0.8.36`, EVM `osaka`, optimizer `200` runs (Calibur: `1000`). Deployer `0x33a573…2645`.
Stateful contracts need a proxy + `initialize`. `DecimalQuoter` and `SwapRouter` are stateless: call the implementation.
| Contract | Implementation |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| ERC1967Factory | [`0xB286994c648F98fD3a3BA6C43934828a5b88162b`](https://gnosisscan.io/address/0xB286994c648F98fD3a3BA6C43934828a5b88162b) |
| ContractRegistry | [`0x273B65EA845E2832f41d8d4366E6Cc3a9Fc67186`](https://gnosisscan.io/address/0x273B65EA845E2832f41d8d4366E6Cc3a9Fc67186) |
| AccountsIndex | [`0x3cA9AB9b8628b43f1A1c53f29e969BA84Ef88BeE`](https://gnosisscan.io/address/0x3cA9AB9b8628b43f1A1c53f29e969BA84Ef88BeE) |
| TokenUniqueSymbolIndex | [`0x0CEB18BA6562D3227717D98c044A5849bE8362EF`](https://gnosisscan.io/address/0x0CEB18BA6562D3227717D98c044A5849bE8362EF) |
| GiftableToken | [`0x34445d13F112A11f72C1d353a7dcdc407F3df2d8`](https://gnosisscan.io/address/0x34445d13F112A11f72C1d353a7dcdc407F3df2d8) |
| SwapPool | [`0x9e694D342Cab02e295262B2290b0E64A0334160D`](https://gnosisscan.io/address/0x9e694D342Cab02e295262B2290b0E64A0334160D) |
| FeePolicy | [`0x1b97FfFAF2D2e16C8F7de6826F4F658dc898E5b6`](https://gnosisscan.io/address/0x1b97FfFAF2D2e16C8F7de6826F4F658dc898E5b6) |
| Limiter | [`0x258AAd6c933F70D7F071E112800a41Fb60434048`](https://gnosisscan.io/address/0x258AAd6c933F70D7F071E112800a41Fb60434048) |
| RelativeQuoter | [`0x0B0986c0E580377389337C453Cc65A3933511165`](https://gnosisscan.io/address/0x0B0986c0E580377389337C453Cc65A3933511165) |
| OracleQuoter | [`0x3334fd1eA4c7e4dCA51f5E62EE3F5f7Dcbd098BA`](https://gnosisscan.io/address/0x3334fd1eA4c7e4dCA51f5E62EE3F5f7Dcbd098BA) |
| ProtocolFeeController | [`0x302E6d520e7D7AeFceA4813e456234B5daA23B4d`](https://gnosisscan.io/address/0x302E6d520e7D7AeFceA4813e456234B5daA23B4d) |
| Splitter | [`0x3b1F9bCC82f2dA5607dcDFCb21E47Cf64Ee54274`](https://gnosisscan.io/address/0x3b1F9bCC82f2dA5607dcDFCb21E47Cf64Ee54274) |
| CAT | [`0xA12148B6eeb347298F17eCC5AC0377592850202E`](https://gnosisscan.io/address/0xA12148B6eeb347298F17eCC5AC0377592850202E) |
| EthFaucet | [`0x3dF58d637f03CD5174c5533FB89cb9B6fd855Ad3`](https://gnosisscan.io/address/0x3dF58d637f03CD5174c5533FB89cb9B6fd855Ad3) |
| PeriodSimple | [`0x8608051473603279EE982E87f765A78d3D080b00`](https://gnosisscan.io/address/0x8608051473603279EE982E87f765A78d3D080b00) |
| DecimalQuoter | [`0x336f493d5472FD59e9E05128804E2D05Be89c4B7`](https://gnosisscan.io/address/0x336f493d5472FD59e9E05128804E2D05Be89c4B7) |
| SwapRouter | [`0x16e3F29dDe22eF75C081A764C4d200Cd48647bcC`](https://gnosisscan.io/address/0x16e3F29dDe22eF75C081A764C4d200Cd48647bcC) |
| RescueVault | [`0x3E5D8d8f63c57EA5DD62cF5aeC7212C50bDA69EF`](https://gnosisscan.io/address/0x3E5D8d8f63c57EA5DD62cF5aeC7212C50bDA69EF) |
| Calibur | [`0x48A910C8e9FF0b14051b78d0c96dB069E57f0729`](https://gnosisscan.io/address/0x48A910C8e9FF0b14051b78d0c96dB069E57f0729) |
### Celo (legacy)
| | |
| -------------------------------- | -------------------------------------------- |
| ContractRegistry (proxy) | `0xcaeEcFc5359c50365bED9f6dFEaFD332b57d5475` |
| ContractRegistry (SarafuNetwork) | `0xd1FB944748aca327a1ba036B082993D9dd9Bfa0C` |
| AccountIndex | `0x8B73A2C9e88d780cc2a1Cbec0E8B3A366eb11A69` |
| TokenIndex | `0xe2CEf4000d6003958c891D251328850f84654eb9` |
| PoolIndex | `0x281951bc238B5c58649cd97D351De23c16Cfcfa9` |
| ProtocolFeeController proxy | `0x410e7Ff8410B0f8D15CA762a51F8b8A6Cd6eE9BE` |
## Architecture
Two layers: **protocol** (on-chain) and **stack** (off-chain). Apps talk to the stack; the stack talks to Gnosis.
### On-chain
Discovery starts at `ContractRegistry`. It maps identifiers to index proxies.
| Identifier | Contract | What it lists |
| -------------- | ------------------------ | --------------------------------------- |
| `AccountIndex` | `AccountsIndex` | Registered users (clc-core writes here) |
| `TokenIndex` | `TokenUniqueSymbolIndex` | Vouchers |
| `PoolIndex` | `TokenUniqueSymbolIndex` | Pools |
`eth-tracker` walks TokenIndex and PoolIndex, then each pool's `tokenRegistry()`, and watches those addresses. `clc-core` only needs AccountIndex from the registry.
A pool is not one contract. `SwapPool` is the vault; it is injected with a registry, quoter, fee policy, limiter, and optional protocol fee controller. Seal bits lock those slots.
Implementations are shared. Each voucher/pool/index is an ERC-1967 proxy from `ERC1967Factory`. `DecimalQuoter`, `SwapRouter`, `RescueVault`, `ERC1967Factory`, and `Calibur` are plain contracts.
### Off-chain
| Process | Role |
| ------------------ | -------------------------------------------------------------- |
| **eth-tracker** | Follows the chain, filters CLC events, publishes to NATS |
| **eth-indexer** | Consumes NATS, writes `chain_data` |
| **clc-core** | HTTP API, SIWE auth, catalog, deploy jobs, EIP-7702 relayer |
| **storage-server** | Signed uploads to R2; browsers post here, not through clc-core |
| **clc-app** | Web PWA + admin console |
Tracker publishes to NATS; indexer consumes. Indexer does not tail logs.
## Components
Repos under [github.com/cosmo-local-credit](https://github.com/cosmo-local-credit) unless noted.
| Component | Language | Role | Status |
| ------------------------------------------------------------------------------------- | ------------- | -------------------------------------- | --------- |
| [clc-protocol](https://github.com/cosmo-local-credit/protocol) | Solidity / Go | Vouchers, pools, indexes, `ge-publish` | active |
| [calibur](https://github.com/cosmo-local-credit/calibur) | Solidity | EIP-7702 account implementation | active |
| [clc-core](https://github.com/cosmo-local-credit/clc-core) | Go | HTTP API, catalog, deploy, relayer | active |
| [eth-tracker](https://github.com/cosmo-local-credit/eth-tracker) | Go | Chain follower → NATS | active |
| [eth-indexer](https://github.com/cosmo-local-credit/eth-indexer) | Go | NATS → Postgres `chain_data` | active |
| [storage-server](https://github.com/cosmo-local-credit/storage-server) | Go | Signed image uploads (R2) | active |
| [clc-app](https://github.com/cosmo-local-credit/clc-app) | TS | Web PWA + admin | active |
| [ethutils](https://github.com/grassrootseconomics/ethutils) | Go | Registry loaders, RPC helpers | active |
| [gnosis-node](https://github.com/cosmo-local-credit/gnosis-node) | Docker | reth + nimbus full node | active |
| [notification-service](https://github.com/cosmo-local-credit/notification-service) | Go | SMS / email | optional |
| [ens-offchain-resolver](https://github.com/grassrootseconomics/ens-offchain-resolver) | Go | `sarafu.eth` CCIP-read | Celo only |
Images (GHCR, private):
```
ghcr.io/cosmo-local-credit/clc-core:develop
ghcr.io/cosmo-local-credit/eth-tracker:v1.14.0-stable
ghcr.io/cosmo-local-credit/eth-indexer:latest
ghcr.io/cosmo-local-credit/storage-server:v0.8.0-rc
```
Staging compose: `clc-gnosis-repo`. Host `clc-gnosis` on Tailscale.
### License
CLC-authored services and protocol contracts: **AGPL-3.0**, except unmodified Solady (MIT) and Calibur (MIT). This docs repo: **CC-BY-SA-4.0**.
## clc-core
HTTP API, SIWE sessions, catalog, contract deploy jobs, EIP-7702 relayer.
Staging: `https://gnosis-staging.cosmolocal.credit`
### Auth
Two credentials, mutually exclusive on a request:
| | User | Service |
| --------- | ----------------------------------- | ------------------------------------------- |
| Proof | SIWE or WebAuthn | EdDSA JWT from `gen-service-token` |
| Transport | cookie `__clc_auth` (HttpOnly, 30d) | `Authorization: Bearer` or `__ge_cust_auth` |
| Claims | `userId`, `ethereumAddress`, `role` | `publicKey`, `service: true` |
Service tokens skip user quotas and **cannot** hit `/api/v2/users/*`.
SIWE (EOA / paper wallet):
```
POST /auth/nonce → sign EIP-4361 → POST /auth/signin | /auth/signup | /auth/siwe/verify
```
Nonce TTL 5 minutes, one-time. Verification is local (`go-ethereum` ecrecover), no RPC.
Passkey: `POST /auth/webauthn/verify`. No SIWE. Relies on P-256 assertion.
User lifecycle: `PENDING` at signup → superadmin `PUT /api/v2/admin/users/:address/approve` → `APPROVED`. Sign-in of a pending address fails.
`GET /auth/session`: `204` = no session, `200 { address, chainId }` = ok.
### Shape
```
/auth/* public
/api/v2/* cookie or service JWT
/api/v2/users/* user JWT only
/api/v2/admin/* SUPERADMIN
/api/v2/relayer/* see Relayer
```
Public catalog (no auth): `GET /api/v2/vouchers`, `GET /api/v2/vouchers/:address`, offerings under those.
Errors:
```json
{ "ok": false, "description": "Not Found", "code": "E03" }
```
Full route list: `clc-core/docs/API.md` and `docs/swagger.yaml`.
### Chain config
```
CORE_CHAIN__ID=100
CORE_CHAIN__RPC_ENDPOINT=https://gnosis-rpc.publicnode.com
CORE_CHAIN__GE_REGISTRY=0xaC95B1A09f2dbaf4542e3Ba8c4Cce0234895D389
CORE_CHAIN__DIVVI_CONSUMER=0x0000000000000000000000000000000000000000
CORE_RELAYER__CALIBUR_ADDRESS=0x48A910C8e9FF0b14051b78d0c96dB069E57f0729
```
### Keystore
Master signing key is created on first boot and stored in `keystore`. That address must be an AccountsIndex writer. A volume rebuild generates a new key:
```bash
docker compose exec -T postgres psql -U postgres -d clc_core -tAc \
"SELECT public_key FROM keystore;"
cast send 0xB1Ec3942dc9258de62056Febb0dc544D9EB1ED1a \
"addWriter(address)" \
--rpc-url "$RPC_URL" --private-key $DEPLOYER_KEY
```
## eth-indexer
NATS consumer. Writes `chain_data`. Uses RPC for metadata, not for logs.
```
INDEXER_API__ADDRESS=:5002
INDEXER_POSTGRES__DSN=postgres://postgres:@postgres:5432/chain_data
INDEXER_JETSTREAM__ENDPOINT=nats://nats:4222,nats://nats:4222
INDEXER_JETSTREAM__ID=eth-indexer-gnosis-staging
INDEXER_CHAIN__CHAINID=100
INDEXER_CHAIN__RPC_ENDPOINT=https://gnosis-rpc.publicnode.com
```
`INDEXER_JETSTREAM__ID` must be unique per deployment.
Schema: [chain\_data](/data/schema).
## NATS
JetStream, single node on staging (`stream_replicas = 1`).
Stream: `TRACKER.*`. Consumers should be **durable + pull**, and ACK after successful write. Redelivery is the retry.
MsgID `txHash:logIndex` is the dedup key. Keep `dedup_window_hrs == persist_duration_hrs` or a recovered tracker will republish events that are still stored but no longer deduped.
### Subjects and payloads
Envelope is documented on [eth-tracker](/stack/tracker). `payload` by `transactionType`:
| Subject | payload |
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
| `TRACKER.CONTRACT_CREATION` | `{ from }` |
| `TRACKER.CUSTODIAL_REGISTRATION` | `{ account }` |
| `TRACKER.FAUCET_GIVE` | `{ recipient, token, amount }` |
| `TRACKER.INDEX_ADD` / `INDEX_REMOVE` | `{ address }` |
| `TRACKER.INDEX_ACTIVE` | `{ address, active }` |
| `TRACKER.LIMIT_SET` | `{ token, holder, value }` |
| `TRACKER.OWNERSHIP_TRANSFERRED` | `{ previousOwner, newOwner }` |
| `TRACKER.POOL_DEPOSIT` | `{ initiator, tokenIn, amountIn }` |
| `TRACKER.POOL_SWAP` | `{ initiator, tokenIn, tokenOut, amountIn, amountOut, fee }` + optional settlement fields |
| `TRACKER.QUOTER_PRICE_INDEX_UPDATED` | `{ token, exchangeRate }` |
| `TRACKER.QUOTER_UPDATED` | `{ newQuoter }` |
| `TRACKER.SEAL_STATE_CHANGE` | `{ final, sealState }` |
| `TRACKER.TOKEN_APPROVE` | `{ owner, spender, value }` |
| `TRACKER.TOKEN_BURN` / `TOKEN_MINT` / `TOKEN_TRANSFER` | standard ERC-20 fields |
`POOL_SWAP` from `chain.pool_settlement_block` onward adds `quotedAmountOut`, `nominalAmountOut`, `protocolFee` (from `SwapSettlement`). Below that block the extras are **absent**, not zero.
Amounts are raw integers. `fee` and every amount-out are in `tokenOut`.
On reverts the tracker still publishes; `success: false` and some payload fields are the type's zero value.
## Notifications
`notification-service` (Go). SMS via Africa's Talking, email via Resend. Not in the Gnosis compose.
Auth: EdDSA service JWT (`"service": true`). Base `/api/v1`.
OpenAPI: `notification-service/openapi.yaml`.
## Stack
Staging host: Tailscale `clc-gnosis` (`100.108.72.127`), compose in `clc-gnosis-repo`.
| Service | Image | Publish | Watchtower |
| -------------- | -------------------------------- | ---------------- | ---------- |
| postgres 18 | `postgres:18-alpine` | `54432:5432` | no |
| nats | `nats:2` (`-js`) | internal | no |
| eth-tracker | `eth-tracker:v1.14.0-stable` | `127.0.0.1:5001` | no |
| eth-indexer | `eth-indexer:latest` | `127.0.0.1:5002` | yes |
| clc-core | `clc-core:develop` | via Caddy | yes |
| storage-server | `storage-server:v0.8.0-rc` | via Caddy | yes |
| caddy | `caddy-docker-proxy:2.10-alpine` | 80/443 | no |
| watchtower | `containrrr/watchtower` | none | n/a |
Watchtower: `--interval 120 --label-enable --cleanup --rolling-restart`. Only containers with `com.centurylinklabs.watchtower.enable=true`.
Hostnames:
* `gnosis-staging.cosmolocal.credit` → clc-core
* `gnosis-storage.cosmolocal.credit` → storage-server
### Env mapping
`CORE_A__B` → config key `a.b`. A value with a comma or space becomes a **list**. In `core.env` only, a value containing `\n` is passed through unsplit (PEM keys).
Gotchas:
1. **Lists.** koanf `Strings()` does not promote a single env value to `[]string`. Repeat the NATS URL (`nats://nats:4222,nats://nats:4222`) in `core.env` / `indexer.env`. Do not do that for `bootstrap.ge_registry` (empty element panics). Put the registry in `tracker-config.toml`.
2. Tracker reads `jetstream.endpoint` (singular). The image's `endpoints` key is unused.
3. `CORE_CHAIN__DIVVI_CONSUMER` cannot be empty. Use the zero address.
4. Do not mount `config.toml` over Watchtower-updated images. Tracker is pinned, so a mount is safe. clc-core / indexer would freeze on a stale file.
### Ordered start
```bash
docker compose up -d postgres nats
docker compose logs -f postgres | grep -m1 "database system is ready"
docker compose up -d eth-tracker
docker compose up -d eth-indexer clc-core storage-server
docker compose up -d caddy watchtower
```
`init_db.sql` creates `chain_data` only on an empty volume.
## Relayer
Gasless path inside clc-core. User signs, clc-core pays, tx lands as an EIP-7702 Calibur batch.
Base: `/api/v2/relayer`
| Method | Path | |
| ------ | ---------------------------------- | --------------------------------------- |
| GET | `/health` | relayer address (`executor`) + chain id |
| GET | `/nonce/:address` | EOA nonce (first-relay authorization) |
| GET | `/calibur-nonce/:address` | sequence for the root key |
| GET | `/calibur-nonce/:address/:keyHash` | sequence for a passkey |
| POST | `/relay/preview` | digest + resolved nonce |
| POST | `/relay` | submit signed batch |
| POST | `/delegate` | set code without calls |
| GET | `/track/:trackingId` | follow until mined |
| GET | `/webauthn/challenge` | passkey recovery |
| POST | `/webauthn/recover` | recover from passkey |
### Flow
```
build calls → POST /relay/preview → sign digest → POST /relay → GET /track/:id
```
Each call: `{ to, value, data }`. Batch: `{ calls: [1..16], revertOnFailure }`. Use `revertOnFailure: true`.
First relay for a wallet also sends the 7702 authorization (needs `/nonce/:address`). After that, only Calibur sequence numbers.
Passkey sign-in uses the WebAuthn assertion, not SIWE.
Gas limits: `SETCODE_GAS_LIMIT` / `DELEGATE_GAS_LIMIT` = `200000`.
Details and viem examples: `clc-core/docs/RELAYING.md`.
## Storage
`storage-server`. Browsers upload **directly**; clc-core never sees the bytes. Auth is an Ed25519 signature whose public key matches `CORE_API__PRIVATE_KEY`.
Staging: `https://gnosis-storage.cosmolocal.credit`
CDN: `https://cdn.cosmolocal.credit` (R2 bucket `clc`).
```
STORAGE_API__ADDRESS=:5003
STORAGE_API__MAX_BODY_SIZE=8 # MB
STORAGE_API__UPLOAD_TIMEOUT=60s
STORAGE_API__CDN_BASE_URL=https://cdn.cosmolocal.credit
STORAGE_API__ALLOWED_FOLDERS=voucher,profile,pool,report,offering
STORAGE_AUTH__PUBLIC_KEY=
STORAGE_AUTH__CLOCK_SKEW=30s
```
Generate the pair once:
```bash
openssl genpkey -algorithm ED25519 -out private.pem
openssl pkey -in private.pem -pubout -out public.pem
awk '{printf "%s\\n", $0}' private.pem # CORE_API__PRIVATE_KEY
awk '{printf "%s\\n", $0}' public.pem # STORAGE_AUTH__PUBLIC_KEY
```
clc-core mints a short-lived storage token: `POST /auth/storage-token` (authenticated). The app puts that on the upload request.
Allowed image widths: `400,800,1280`. Method `6`, quality `82`, max 12.5 MP.
## eth-tracker
Follows Gnosis, filters CLC-related txs (including reverts), publishes to NATS JetStream.
Pinned image: `ghcr.io/cosmo-local-credit/eth-tracker:v1.14.0-stable`. API `127.0.0.1:5001`.
### Bootstrap
Default: load `bootstrap.ge_registry`, resolve TokenIndex + PoolIndex, walk entries, then each pool's `tokenRegistry()`. Cache lives in BoltDB (`tracker_data`) and in memory.
Watchlist mode (`bootstrap.watchlist` non-empty): skip the registry, use that set, then apply `blacklist`.
Runtime updates from `AddressAdded` / `AddressRemoved`.
Staging:
```toml
[bootstrap]
ge_registry = ["0xaC95B1A09f2dbaf4542e3Ba8c4Cce0234895D389"]
watchlist = [""]
blacklist = [""]
contracts = ["0xB286994c648F98fD3a3BA6C43934828a5b88162b"]
```
`contracts` is extra addresses always watched (here: the factory).
### Chain
```
TRACKER_CHAIN__CHAINID=100
TRACKER_CHAIN__START_BLOCK=48027888
TRACKER_CHAIN__POOL_SETTLEMENT_BLOCK=0
TRACKER_CHAIN__RPC_ENDPOINT=https://gnosis-rpc.publicnode.com
TRACKER_CHAIN__WS_ENDPOINT=wss://gnosis-rpc.publicnode.com
```
Losing `tracker_data` restarts catchup from `START_BLOCK`.
Staging `pool_size = 4`. See [RPC](/chain/rpc).
### JetStream
```
TRACKER_JETSTREAM__PERSIST_DURATION_HRS=48
TRACKER_JETSTREAM__DEDUP_WINDOW_HRS=48 # must equal persist
TRACKER_JETSTREAM__STREAM_REPLICAS=1 # single-node NATS
```
Dedup id: `txHash:logIndex`. Replicas `3` on one node never reaches quorum.
Config key is `jetstream.endpoint` (singular).
### Subjects
Stream `TRACKER.*`. Payload envelope:
```json
{
"block": 0,
"contractAddress": "0x…",
"success": true,
"timestamp": 0,
"transactionHash": "0x…",
"transactionType": "POOL_SWAP",
"index": 0,
"payload": {}
}
```
`index` is the log index. `0` on reverted input-data events. `max uint64` on `CONTRACT_CREATION`.
See [NATS](/stack/nats) for per-type payloads.
## ethutils
Go helpers: addresses, registry load, index walk, balances scanner, simulate, reverted-reason dump.
```bash
go get github.com/grassrootseconomics/ethutils
```
LGPL-3.0. Docs: [pkg.go.dev/github.com/grassrootseconomics/ethutils](https://pkg.go.dev/github.com/grassrootseconomics/ethutils).
### Registry
`GetGESmartContracts(rpc, registry)` resolves registry identifiers. Missing / zero addresses are omitted. Tracker uses this to seed the watchlist.
Identifier bytes32 = ASCII name, right-padded: `cast format-bytes32-string AccountIndex`.
`getAllTokensFromTokenIndex` walks `entryCount` / `entry` on both TokenIndex and PoolIndex.
## ge-publish
Go library + CLI in `clc-protocol` (`pkg/publish`, `cmd/ge-publish`).
```bash
go build -o ./ge-publish ./cmd/ge-publish
```
| Subcommand | Does |
| ---------------------------------------------------------------------------------- | ------------------------------------------------- |
| `publish-one` | factory (if needed) + impl + proxy + `initialize` |
| `deploy-impl` | bytecode only |
| `deploy-proxy` | proxy against an existing factory + impl |
| `publish-one` on `decimalquoter` / `swaprouter` / `rescuevault` / `erc1967factory` | plain CREATE |
Factory: Arachnid CREATE2 (`0x4e59…`) when `--factory-address` is omitted. On Gnosis reuse [`0xB286994c…162b`](https://gnosisscan.io/address/0xB286994c648F98fD3a3BA6C43934828a5b88162b).
`--admin` is required for proxies and must differ from `--owner`.
Deterministic proxy salts: high 20 bytes = deployer, low 12 bytes = ASCII tag (`contractregi`, `accountsinde`, `tokenuniques`, `poolindex` + 3 zero bytes). Two proxies of the same contract name collide unless you override the tag, which is why PoolIndex is `poolindex`.
Go API, upgrade, and CREATE2 recipes: `clc-protocol/docs/PUBLISH.md`. Deploy flags: [Deploy](/contracts/deploy).
## chain\_data
Postgres schema written by eth-indexer. Full column notes: `eth-indexer/DATA_GUIDE.md`.
Every event row FKs to `tx` via `tx_id`. Join `contract_address` to `tokens` / `pools` for metadata.
### Tables
| Table | Contents |
| --------------------------- | -------------------------------------------------------- |
| `tx` | `tx_hash`, `block_number`, `date_block` (UTC), `success` |
| `token_transfer` | ERC-20 `Transfer` |
| `token_mint` / `token_burn` | mint / burn |
| `faucet_give` | faucet payouts |
| `pool_swap` | `Swap` / `SwapSettlement` |
| `pool_deposit` | deposits (including the deposit half of a swap) |
| `index_active` | activation flips on an index |
| `ownership_change` | `OwnershipTransferred` |
| `tokens` / `pools` | metadata; `removed=true` means delisted, row kept |
Idempotency: `(tx_id, …, log_index)`.
Values are raw integers. Scale with `power(10, token_decimals)`.
`pool_swap.fee`, `protocol_fee`, and all out amounts are in `token_out`. `quoted_out_value` / `nominal_out_value` / `protocol_fee` are NULL below `pool_settlement_block`. Treat NULL as unknown, not zero.
### Example
```sql
SELECT
tt.contract_address,
tk.token_symbol,
COUNT(*) AS transfer_count,
SUM(tt.transfer_value / power(10, tk.token_decimals)) AS total_volume
FROM token_transfer tt
JOIN tx ON tx.id = tt.tx_id
JOIN tokens tk ON tk.contract_address = tt.contract_address
WHERE tx.success = true AND tk.removed = false
GROUP BY tt.contract_address, tk.token_symbol
ORDER BY total_volume DESC;
```
## Calibur
Minimal **non-upgradeable** EIP-7702 delegation target. User EOAs set code to Calibur; clc-core relays batches and pays gas.
Gnosis: [`0x48A910C8e9FF0b14051b78d0c96dB069E57f0729`](https://gnosisscan.io/address/0x48A910C8e9FF0b14051b78d0c96dB069E57f0729) (v1.1.0, solc 0.8.36 / osaka / 1000 runs). Commit `249cac5`.
Not a proxy. Set `CORE_RELAYER__CALIBUR_ADDRESS` to this.
Delegated EOA code:
```
0xef0100 || 48A910C8e9FF0b14051b78d0c96dB069E57f0729
```
* ERC-4337 / ERC-7821-style batched calls (clc-core caps at 16)
* ERC-1271 signatures
* WebAuthn / P-256 keys
* ERC-7914 hooks (do not deploy the example hooks in the repo)
Audits: OpenZeppelin 05/2025, Cantina 04/2025. MIT.
### Config
```
CORE_RELAYER__CALIBUR_ADDRESS=0x48A910C8e9FF0b14051b78d0c96dB069E57f0729
CORE_RELAYER__SETCODE_GAS_LIMIT=200000
CORE_RELAYER__DELEGATE_GAS_LIMIT=200000
CORE_RELAYER__RELYING_PARTY_ID=
CORE_RELAYER__ALLOWED_ORIGINS=
```
Passkeys bind to `RELYING_PARTY_ID`. A mismatch fails sign-in with `origin not allowed`.
See [Relayer](/stack/relayer) for the HTTP flow.
## Deploy
Full recipes: `clc-protocol/docs/DEPLOY.md` and `docs/PUBLISH.md`.
### Tooling
```bash
cd clc-protocol
go build -o ./ge-publish ./cmd/ge-publish
make all
export RPC_URL=https://gnosis-rpc.publicnode.com
export CHAIN_ID=100
export PRIVATE_KEY=
export OWNER=
export ADMIN=
GAS="--gas-fee-cap 2000000000 --gas-tip-cap 1000000000"
BASE="--rpc-url $RPC_URL --chain-id $CHAIN_ID --private-key $PRIVATE_KEY $GAS"
FACTORY=0xB286994c648F98fD3a3BA6C43934828a5b88162b
```
Implementations are already on chain. Reuse them. See [Addresses](/addresses).
### New proxy
```bash
./ge-publish deploy-proxy --contract swappool $BASE \
--factory-address $FACTORY \
--impl-address 0x9e694D342Cab02e295262B2290b0E64A0334160D \
--owner $OWNER --admin $ADMIN
# plus contract-specific flags: --token-name, --fee-policy-default, …
```
`--admin` is required and must differ from `--owner` or `ge-publish` exits.
```bash
cast send $REGISTRY "set(bytes32,address)" \
$(cast format-bytes32-string AccountIndex) $ACCOUNT_INDEX_PROXY \
$BASE
```
`set` is once per identifier.
### Verify
```bash
VERIFY="--chain-id 100 --compiler-version 0.8.36 --evm-version osaka \
--num-of-optimizations 200 --verifier blockscout \
--verifier-url https://gnosisscan.io/api/"
forge verify-contract $IMPL src/SwapPool.sol:SwapPool $VERIFY
```
Gnosisscan links ERC-1967 proxies to a verified implementation. You do not verify the proxy separately.
Calibur uses 1000 optimizer runs, not 200.
### After a pool exists
1. `register` it on PoolIndex (writer/owner).
2. `register` its vouchers on TokenIndex and the pool's own registry.
3. `setLimitFor` on a later tx than pool deploy.
4. If the pool emits `SwapSettlement`, set `TRACKER_CHAIN__POOL_SETTLEMENT_BLOCK`.
## EthFaucet + PeriodSimple
Native xDAI faucet. Registry and period checker are both required.
**Proxy:** yes.
```solidity
initialize(owner, amount)
gimme() // pay msg.sender
giveTo(recipient)
check(recipient) // false (no revert) if a gate is unset
setAmount / setRegistry / setPeriodChecker
seal(state)
withdraw(recipient, value) // owner, remains available after seal
```
After `initialize`, `registry` and `periodChecker` are zero. Every claim reverts (`RegistryBackend`, then `PeriodBackend`) until both are set. Wire them **before** funding.
Expected backends:
* `registry.have(address) → bool`: typically AccountsIndex
* `periodChecker.have / poke / next`: PeriodSimple
Payout uses `transfer` (2300 gas). Contract recipients with heavy `receive` will fail.
Seal bits: `REGISTRY_STATE=1`, `PERIODCHECKER_STATE=2`, `VALUE_STATE=4`. Cannot seal an unset field.
### PeriodSimple
```solidity
initialize(owner, poker)
have(subject) // false if balance ≥ threshold; else cooldown
poke(subject) // owner or poker; records lastUsed
setPeriod / setPoker / setBalanceThreshold
```
`balanceThreshold > 0` and `subject.balance >= threshold` → ineligible, including first-time.
## Fees
Two layers: pool fee (steward) and protocol fee (network). Both come out of the user's output. The pool owner always receives the full pool fee.
### FeePolicy
**Proxy:** yes.
* `defaultFee` in PPM, `<= 1_000_000`.
* Optional per-pair override, **direction-sensitive** (`A→B` ≠ `B→A`).
* Pair fee of `0` is treated as unset → falls back to default. You cannot set a literal zero pair fee; `removePairFee` instead.
```solidity
getFee(tokenIn, tokenOut)
calculateFee(tokenIn, tokenOut, amount) // amount * fee / PPM
setDefaultFee / setPairFee / removePairFee // owner
```
`isActive()` always returns true (interface stub).
### ProtocolFeeController
**Proxy:** yes. One per network, shared by pools.
```solidity
initialize(owner, initialFee, initialRecipient) // recipient ≠ 0, starts active
getProtocolFee() // 0 if inactive
getProtocolFeeRecipient()
setProtocolFee / setProtocolFeeRecipient / setActive
```
Implementation: [`0x302E6d52…23B4d`](https://gnosisscan.io/address/0x302E6d520e7D7AeFceA4813e456234B5daA23B4d).
### How SwapPool combines them
```
totalFee = quoted * feePpm / PPM
assumedFee = quoted * 10_000 / PPM // 1% floor
effectiveFee = max(totalFee, assumedFee)
protocolFee = effectiveFee * protocolFeePpm / PPM
net = quoted - totalFee - protocolFee
```
Skipped if controller unset, `protocolFeePpm == 0`, or recipient is zero.
Joint constraint: `feePpm * (PPM + protocolFeePpm) < PPM²` whenever `feePpm >= 10_000`. Else `FeeTooHigh` on quote and swap, including dust sizes.
### Splitter
**Proxy:** yes. Pays ETH or ERC-20 to a fixed recipient set.
Allocations in PPM must sum to exactly `1_000_000`. ≥2 recipients, no duplicates/zero/self. Only the hash of `(accounts, percentAllocations)` is stored; distribute calls must pass the same arrays (`InvalidHash` otherwise).
Fractional remainders accrue. Empty balance is a no-op.
## GiftableToken
ERC-20 voucher: mint, optional expiry, writer minters.
**Proxy:** yes.
```solidity
initialize(name, symbol, decimals, owner, expiresAt) // expiresAt=0 → never
mintTo(to, amount) // owner or writer
burn(amount) // owner, from own balance
applyExpiry() // anyone; no-op if expiresAt=0
addWriter / deleteWriter / isWriter
```
### Behaviour
* After expiry, every transfer including mint reverts `TokenExpired`.
* Expiry flips on the first transfer at `block.timestamp >= expiresAt`, or via `applyExpiry()`.
* Public: `expired`, `totalMinted`, `totalBurned`.
Events: `Mint`, `Burn`, `Expired`, `WriterAdded`, `WriterRemoved`.
Many instances, one implementation (`0x34445d13…df2d8`). Deploy a proxy per voucher. Symbol must be unique in each `TokenUniqueSymbolIndex` you register it into.
## Indexes
Two contracts, three proxies.
### AccountsIndex
Enumerable address set with activation. clc-core calls `add` with its master key on user registration.
Proxy: [`0xB1Ec3942dc9258de62056Febb0dc544D9EB1ED1a`](https://gnosisscan.io/address/0xB1Ec3942dc9258de62056Febb0dc544D9EB1ED1a).
| Call | Who | Effect |
| --------------------------- | -------------- | ---------------------------------------- |
| `add(account)` | owner / writer | insert; revert if present |
| `remove(account)` | owner / writer | swap-and-pop (order not stable) |
| `activate` / `deactivate` | owner / writer | toggle `have` without dropping the entry |
| `have(account)` | anyone | present and active |
| `contains(account)` | anyone | present, including deactivated |
| `entry(i)` / `entryCount()` | anyone | enumerate |
| `time(account)` | anyone | add timestamp; `NotFound` if absent |
Deactivation: `contains` stays true, `have` / `isActive` go false.
`add` reverts `Access()` unless `writers[msg.sender] || msg.sender == owner()`.
Events: `AddressAdded`, `AddressRemoved`, `AddressActive(account, active)`, `WriterAdded`, `WriterDeleted`.
### TokenUniqueSymbolIndex
Token registry keyed by unique ERC-20 `symbol()`. Used as:
* Network **TokenIndex**
* Network **PoolIndex** (pools, not tokens; same ABI)
* Per-pool `SwapPool.tokenRegistry`
| Call | Notes |
| ------------------------------------- | ------------------------------------------------------------------------- |
| `register(token)` / `add(token)` | reads `symbol()`; unique address + unique symbol; ≤32 bytes; owner/writer |
| `remove(token)` | owner/writer |
| `have(token)` | SwapPool whitelist check |
| `addressOf(bytes32 symbolKey)` | lookup |
| `entry(i)` / `entryCount()` | enumerate addresses |
| `identifier(i)` / `identifierCount()` | enumerate symbol keys |
`time` / `activate` / `deactivate` are inert stubs (`0` / `false`) for interface compatibility.
`isWriter` on this contract is the raw mapping. It does **not** treat owner as writer.
Events: `AddressKey(symbol, token)`, `AddressAdded`, `AddressRemoved`.
Tracker bootstrap walks TokenIndex and PoolIndex with `entryCount` / `entry`, then each pool's `tokenRegistry()`.
## Limiter
Per-token, per-holder balance cap. SwapPool checks it on deposit against **the pool's own balance**.
**Proxy:** yes.
```solidity
initialize(owner)
limitOf(token, holder) // 0 = no cap set
setLimitFor(token, holder, value) // owner or writer
```
SwapPool treats `limitOf == 0` as **block all deposits** for that token. Always set a positive cap before listing.
`setLimitFor` requires `holder` to be a contract (`extcodesize > 0`). You cannot set a limit in the same transaction that deploys the pool: `extcodesize` is 0 in a constructor. Second tx.
Event: `LimitSet(token, holder, value)`.
## Other contracts
### CAT
On-chain preference list: up to 5 ERC-20s per account, index 0 most preferred.
```solidity
setTokens(tokens[]) // caller
setTokensFor(account, tokens[]) // owner or writer
getTokens / tokenAt / tokenCount
```
Replace-all, not a delta. No zero addresses.
### RescueVault
Plain CREATE. Occupies a deployer-nonce address so assets sent to a "future contract" can be swept.
```solidity
constructor(admin) // payable, admin ≠ 0
sweepETH / sweepERC20 / sweepERC20s
sweepERC721 / sweepERC721s
sweepERC1155 / sweepERC1155Batch
```
Admin only. Recipient ≠ 0. Implements ERC-721/1155 receiver hooks. Assets are not enumerable. Pass token addresses / IDs.
Instance: [`0x3E5D8d8f63c57EA5DD62cF5aeC7212C50bDA69EF`](https://gnosisscan.io/address/0x3E5D8d8f63c57EA5DD62cF5aeC7212C50bDA69EF).
## Contracts
Source: [clc-protocol](https://github.com/cosmo-local-credit/protocol). Spec: `docs/SPEC.md`.
License: AGPL-3.0 (Solady snippets stay MIT).
### Units
Fees and rates use **PPM**: `1_000_000 = 100%`. `10_000 = 1%`. `100_000 = 10%`.
### Deployment pattern
Stateful contracts: implementation (`_disableInitializers` in constructor) + ERC-1967 proxy from Solady `ERC1967Factory` + one-time `initialize`.
Plain (no proxy): `ERC1967Factory`, `DecimalQuoter`, `SwapRouter`, `RescueVault`, `Calibur`.
`ge-publish` refuses `--admin == --owner`. Seal on SwapPool / EthFaucet is only a commitment if a different key can `upgradeTo`.
| Contract | Count | Job |
| --------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ---------------------------------- |
| [GiftableToken](/contracts/giftable-token) | many | Voucher ERC-20 |
| [SwapPool](/contracts/swap-pool) | many | Vault + swap |
| [TokenUniqueSymbolIndex](/contracts/indexes) | per pool + network Token/Pool indexes | `have(token)` whitelist |
| [Limiter](/contracts/limiter) | per pool | Deposit caps |
| [FeePolicy](/contracts/fees) | per pool | Swap fee PPM |
| [DecimalQuoter](/contracts/quoters) / [RelativeQuoter](/contracts/quoters) / [OracleQuoter](/contracts/quoters) | shared or per pool | `valueFor` / `reverseValueFor` |
| [ProtocolFeeController](/contracts/fees) | one per network | Network fee |
| [SwapRouter](/contracts/router) | one | Multi-hop quotes (`eth_call` only) |
| [ContractRegistry](/contracts/registry) | one | Identifier → address |
| [AccountsIndex](/contracts/indexes) | one | Registered users |
| [Calibur](/contracts/calibur) | one impl | EIP-7702 account |
All proxied contracts reject `owner == address(0)` in `initialize`.
### Writer role
Owner grants limited write access without handing over ownership: `GiftableToken` (minters), `Limiter`, `CAT`, `AccountsIndex`, `TokenUniqueSymbolIndex`.
`isWriter` includes the owner **except** on `TokenUniqueSymbolIndex`, where it is the raw flag.
### Quoter interface
```
valueFor(out, in, amount) // forward
reverseValueFor(out, in, desiredOut) // inverse, rounded up
```
Guarantee: `valueFor(out, in, reverseValueFor(out, in, x)) >= x`. SwapPool uses the forward path for swaps / `getAmountOut` and the reverse path for `getAmountIn`.
## Quoters
All implement `IQuoter`. SwapPool calls them during swap / quote.
### DecimalQuoter
Stateless, no proxy. Implementation is the instance: [`0x336f493d5472FD59e9E05128804E2D05Be89c4B7`](https://gnosisscan.io/address/0x336f493d5472FD59e9E05128804E2D05Be89c4B7).
1:1 value, scales decimals only. Use when every voucher in the pool is the same unit (e.g. all KES-pegged, mixed 6/18 decimals).
### RelativeQuoter
**Proxy:** yes. Per-token rate in PPM. Unset rate = `1_000_000` (1.0).
```
out = adjustedValue * inRate / outRate
```
`adjustedValue` applies the decimal delta **before** the rate, so cross-decimal quotes keep precision.
Owner: `setPriceIndexValue(token, exchangeRate)`. Event: `PriceIndexUpdated`.
### OracleQuoter
**Proxy:** yes. Chainlink `AggregatorV3` feeds, all in one quote denomination (typically USD). Cross-rates any pair that shares that denominator.
```solidity
initialize(owner, baseCurrency) // baseCurrency is metadata only
setOracle(token, feed)
setOracle(token, feed, maxStaleness) // 0 → global fallback
setMaxStaleness(seconds) // default 86400
setMultiplier(ppm) // 900_000–1_000_000 inclusive
```
Missing / stale / non-positive price reverts (`OracleNotSet`, `StaleOraclePrice`, `InvalidOraclePrice`). No fallback rate.
`multiplier` haircuts every quote. Above parity is rejected (same factor on both legs, so A→B→A would return `multiplier²`). Uninitialized storage (`0`) is treated as `1_000_000`.
| Pool | Quoter |
| ------------------------- | -------------- |
| Same unit, mixed decimals | DecimalQuoter |
| Steward-set rates | RelativeQuoter |
| Live FX / stables | OracleQuoter |
## ContractRegistry
`bytes32` identifier → address. Identifiers are fixed in `initialize`; each `set` can run once.
Proxy: [`0xaC95B1A09f2dbaf4542e3Ba8c4Cce0234895D389`](https://gnosisscan.io/address/0xaC95B1A09f2dbaf4542e3Ba8c4Cce0234895D389).
```solidity
function initialize(address owner_, bytes32[] memory _identifiers) external initializer;
function set(bytes32 _identifier, address _address) external onlyOwner returns (bool);
function addressOf(bytes32) external view returns (address);
function identifier(uint256) external view returns (bytes32);
function identifierCount() external view returns (uint256);
```
Event: `AddressKey(identifier, address)`.
The key is ASCII, right-padded: `cast format-bytes32-string AccountIndex`. Case matters.
This registry has `AccountIndex`, `TokenIndex`, `PoolIndex`. clc-core reads AccountIndex; eth-tracker reads TokenIndex and PoolIndex.
### Config
Same address in `CORE_CHAIN__GE_REGISTRY` and tracker `bootstrap.ge_registry`. Tracker cannot take a single env value (koanf `Strings()` treats it as a scalar), so put it in `tracker-config.toml` and leave `TRACKER_BOOTSTRAP__GE_REGISTRY` unset:
```toml
[bootstrap]
ge_registry = ["0xaC95B1A09f2dbaf4542e3Ba8c4Cce0234895D389"]
```
## SwapRouter
Stateless multi-hop quoter. Holds no funds, executes no swaps. Call via `eth_call` (functions are non-`view` because pool quotes may be non-view).
Instance: [`0x16e3F29dDe22eF75C081A764C4d200Cd48647bcC`](https://gnosisscan.io/address/0x16e3F29dDe22eF75C081A764C4d200Cd48647bcC).
```solidity
struct Hop { address pool; address tokenIn; address tokenOut; }
quoteExactInput(Hop[] path, uint256 amountIn) returns (uint256 amountOut)
quoteExactOutput(Hop[] path, uint256 amountOut) returns (uint256 amountIn)
```
Empty path → `EmptyPath`. Any hop revert (including `FeeTooHigh` / `InsufficientOutput`) fails the whole quote. There is no `0` meaning "no route".
Exact-output walks the path backwards using `getAmountIn`. Roundtrip: `quoteExactInput(path, quoteExactOutput(path, X)) >= X` (+1 wei safety per hop).
```solidity
Hop[] memory path = new Hop[](2);
path[0] = Hop(poolA, usdt, voucher);
path[1] = Hop(poolB, voucher, other);
uint256 out = router.quoteExactInput(path, 100e6);
```
## SwapPool
Vault + swap engine. ERC-20 metadata (name/symbol/decimals) but **no LP tokens**.
**Proxy:** yes. Implementation [`0x9e694D34…4160D`](https://gnosisscan.io/address/0x9e694D342Cab02e295262B2290b0E64A0334160D).
### Injected deps
Set in `initialize` or later via setters, until sealed.
| Slot | Interface | If unset |
| ----------------------- | ---------------------------- | ---------------------- |
| `tokenRegistry` | `have(address)` | any token allowed |
| `tokenLimiter` | `limitOf(token, pool)` | uncapped |
| `quoter` | `IQuoter` | 1:1 |
| `feePolicy` | `getFee` | pool fee 0 |
| `feeAddress` | n/a | fees not accrued |
| `protocolFeeController` | `getProtocolFee` + recipient | no protocol fee |
| `feesDecoupled` | n/a | fees stay in inventory |
### Swap
Prefer the bounded form:
```solidity
withdraw(tokenOut, tokenIn, value, recipient, minAmountOut, deadline)
```
Shorter overloads send to `msg.sender` and skip slippage/deadline.
Flow (`nonReentrant`):
1. `tokenIn == tokenOut` → `InvalidToken`.
2. Deposit `tokenIn`. Registry + limiter apply. `received` is the **balance delta** (fee-on-transfer safe). Zero delta → `TransferFailed`. Emits `Deposit`.
3. `quotedValue = quoter.valueFor(tokenOut, tokenIn, received)` (or `received` if no quoter).
4. Pool fee: `quotedValue * feePpm / PPM`.
5. Protocol fee on top (see [Fees](/contracts/fees)).
6. Recipient gets `quotedValue - poolFee - protocolFee`. Protocol cut is transferred in the same call.
7. Pool fee recorded in `fees[tokenOut]` if `feeAddress != 0`.
8. `Swap` + `SwapSettlement` events.
`Swap.initiator` is always `msg.sender`. Expect a `Deposit` immediately before each `Swap`.
`getAmountOut` / `getAmountIn` **revert** instead of returning 0 (`FeeTooHigh`, `InsufficientOutput`). Do not treat a revert as a zero quote.
### Seal
Bits can only be set, never cleared.
| Constant | Value | Locks |
| ------------------ | ----- | --------------- |
| `FEE_STATE` | 1 | `feePolicy` |
| `FEEADDRESS_STATE` | 2 | `feeAddress` |
| `QUOTER_STATE` | 4 | `quoter` |
| `REGISTRY_STATE` | 8 | `tokenRegistry` |
| `LIMITER_STATE` | 16 | `tokenLimiter` |
| `maxSealState` | 31 | all five |
Sealing an unset required slot reverts `InvalidState`. Seal locks the **slot**, not the callee. A sealed quoter can still change its own rates.
`isSealed(0)` compares against the mask stored at init (`fullSealMask`), so adding a bit in a later implementation does not silently unseal old pools.
### Fees in inventory
* `feesDecoupled = false` (default): accrued fees count as liquidity.
* `true`: available = `balance - fees[token]`. `withdrawLiquidity` cannot take the reserved fees.
Owner collects with `withdraw(tokenOut)` / `withdraw(tokenOut, value)` to `feeAddress`.
Owner emergency: `withdrawLiquidity(token, to, amount)`.
### Constants
`DEFAULT_FEE_PPM = 10_000` (1%). Floor used when computing the protocol cut, so a pool cannot shrink the protocol's share by setting a tiny pool fee.
## Gnosis
| | |
| --------------- | -------------------------------------- |
| Chain ID | `100` |
| Native token | xDAI |
| Block time | \~5s |
| Explorer | [gnosisscan.io](https://gnosisscan.io) |
| CLC start block | `48027888` |
```
CORE_CHAIN__ID=100
TRACKER_CHAIN__CHAINID=100
INDEXER_CHAIN__CHAINID=100
TRACKER_CHAIN__START_BLOCK=48027888
```
`START_BLOCK` is the registry deploy. `POOL_SETTLEMENT_BLOCK` stays `0` until a pool emits `SwapSettlement`; then set it to that pool's first block.
### EIP-7702
Gnosis supports EIP-7702. clc-core delegates user EOAs to [Calibur](/contracts/calibur) and relays batches. First relay for a wallet sets the designation (`0xef0100` + Calibur address).
## Full node
Repo: `gnosis-node`. Docker Compose: **reth** (execution) + **nimbus** (consensus).
### Hardware
From the [Gnosis node docs](https://docs.gnosischain.com/node):
* 4+ CPU threads
* 16 GB RAM
* 1 TB+ NVMe
* Unmetered or high-cap network
Open inbound `30303/tcp+udp` (reth P2P) and `9000/tcp+udp` (nimbus P2P). RPC `8545`, WS `8546`, engine `8551`, beacon `5052` bind `127.0.0.1` only.
### Bring-up
```bash
apt update && apt upgrade --yes
apt install curl chrony git openssl xxd
curl -fsSL https://get.docker.com | bash
cd gnosis-node
vi reth.env nimbus.env
./bootstrap.sh # JWT, datadirs, reth snapshot, nimbus checkpoint
docker compose up -d
```
Skip snapshot / checkpoint:
```bash
RETH_SNAPSHOT=false NIMBUS_CHECKPOINT=false ./bootstrap.sh
```
Keep chrony running. Consensus clients are sensitive to clock drift.
### Operate
```bash
docker compose logs -f --tail=200 reth
docker compose logs -f --tail=200 nimbus
```
Client flags live in `reth.env` / `nimbus.env` and are mapped by `scripts/*.sh`. Restart the service after edits.
Optional Caddy in `caddy/` if you need TLS in front of RPC. Do not expose engine API.
## RPC
Staging uses **publicnode** for every process. WS and HTTP must be the same provider.
```
https://gnosis-rpc.publicnode.com
wss://gnosis-rpc.publicnode.com
```
Mixing providers (WS on A, HTTP on B) produces `w3: call failed: not found` on some blocks: the websocket announces a head the HTTP node does not have yet.
eth-tracker batches up to 100 JSON-RPC sub-requests per call. Default `pool_size = 0` is `runtime.NumCPU()*3`. Staging sets `pool_size = 4` in `tracker-config.toml`.
### Self-hosted
Run [gnosis-node](/chain/node) (reth + nimbus) and point:
```
TRACKER_CHAIN__RPC_ENDPOINT=http://127.0.0.1:8545
TRACKER_CHAIN__WS_ENDPOINT=ws://127.0.0.1:8546
CORE_CHAIN__RPC_ENDPOINT=http://127.0.0.1:8545
INDEXER_CHAIN__RPC_ENDPOINT=http://127.0.0.1:8545
```
Archive (or a node that has logs from block `48027888`) is required for tracker catchup.
## Admin
`clc-app` workspace, `apps/admin`. Superadmin console.
```bash
vp dev apps/admin # :3001
```
Same API and session cookie as web. Routes under `/api/v2/admin/*` require `role = SUPERADMIN`.
Origins must be on `CORE_API__CORS` and `CORE_RELAYER__ALLOWED_ORIGINS` (`https://admin.gnosis.cosmolocal.credit`, `https://clc-admin-gnosis.pages.dev`, previews).
## Web app
`clc-app` workspace, `apps/web`. PWA. Vite+.
```bash
vp install
vp dev apps/web # :3000
vp build
vp test
```
Node version is `.node-version` in that repo.
### Auth model
Public market is the default route. A connected wallet is not enough.
Authenticated ⇔ wagmi connected **and** `GET /auth/session` returns that same address.
| Client state | Meaning |
| -------------------- | ----------------------------------------------- |
| `loading` | reconnect / session in flight |
| `guest` | no wallet |
| `connectedNoSession` | wallet up, no matching cookie |
| `authenticated` | wallet + session match |
| `sessionCheckError` | 5xx / network. Do not demote mounted private UI |
Connectors:
* `private-key`: paper wallet, SIWE
* `calibur-webauthn`: passkey, `POST /auth/webauthn/verify`
### Gnosis wiring
Point the app at:
```
https://gnosis-staging.cosmolocal.credit # API
https://gnosis-storage.cosmolocal.credit # uploads
```
Chain ID `100`. Calibur `0x48A910C8e9FF0b14051b78d0c96dB069E57f0729`.
`CORE_RELAYER__ALLOWED_ORIGINS` on the API host must include the Pages hostname (`https://app.gnosis.cosmolocal.credit`, `https://clc-app-gnosis.pages.dev`, preview `https://*.clc-app-gnosis.pages.dev`).
Passkeys bind to `CORE_RELAYER__RELYING_PARTY_ID` (`gnosis.cosmolocal.credit` on staging).