DukaPay is an on-chain agent-banking float & settlement protocol on Stellar. This document describes the system at the level needed to work on it: what lives on-chain, what lives off-chain, and why that split exists.
| Role | Description |
|---|---|
| Agent | Local shop owner. Holds float (cash + e-money), converts one into the other. KYC'd and bonded. |
| Operator | Trusted backend role (DukaPay deployment). Onboards agents, runs settlement, adjudicates disputes. |
| Customer | Unbanked cash user. Hands cash to an agent, receives stablecoin (or mobile-money credit), and back. |
| Regulator | Read-only. Verifies solvency: Σ float ≤ Σ collateral. |
| Owner | Contract deployer/admin. Sets operator, global params. |
- Collateral — USDC locked by an agent as backing for issued e-money. Stable by construction, so no oracle.
- Float — on-chain liability owed by the agent to customers (e-money in circulation). Governed by
float ≤ collateral × haircut. - Haircut — solvency buffer parameter (e.g. 0.8 means float can be at most 80% of collateral).
- Bond — additional USDC locked at onboarding to guarantee agent behavior; frozen while the agent carries float.
- Batch — a time-windowed set of transactions that net to zero and finalize atomically.
| Layer | Where | Why |
|---|---|---|
| Float ledger, collateral, bonds, agent registry, net settlement, disputes | On-chain (Soroban contracts) | Multi-party trust with real money — needs shared, auditable, immutable state. Collateralization and net settlement are consensus problems, not CRUD problems. |
| KYC document checks, mobile-money operator adapters, dispute evidence, maps, dashboards | Off-chain (backend + frontend) | Private, regulatory, or UX concerns — not money movement. |
Trade-off named: permissioned agent registration (KYC'd + bonded agents, operator role) is the default. Permissionless onboarding is a later option, not the default — unlicensed agents would be an AML disaster and no operator would deploy it.
Three Soroban contracts under contracts/. All use checked arithmetic; all external token movement goes through an internal token_client (USDC).
- Storage:
Agent(addr) → AgentInfo { status, kyc_ref, bond_amount, license_expiry, region, reputation }; owner; operator. - Functions:
register(kyc_ref, region, bond),activate,suspend,set_reputation,renew_license,top_up_bond,withdraw_bond. - Invariants:
- No active agent without a bond and a
kyc_ref. - Bond frozen while the agent carries float (enforced on the withdraw path;
agent-vaultrefuseswithdraw_bondwhilefloat > 0).
- No active agent without a bond and a
- Events:
AgentRegistered,AgentStatusChanged,BondUpdated.
- Storage:
Vault(addr) → { collateral, float, haircut, last_settled }; globalParams { max_haircut, min_collateral }. - Functions:
deposit_collateral,withdraw_collateral,mint_float(cash-in),burn_float(cash-out),transfer_float(to, amt)atomic,settle_net(entries). - Invariants:
float ≤ collateral × haircutat every commit.collateral ≥ min_collateral.- On net settlement: Σ entries = 0, every resulting float stays within bounds.
- Risk mitigations: collateral is USDC (no price-feed risk); cross-contract calls only via internal
token_client; bounded batch sizes (no storage DoS).
Earlier design called for a standalone settlement-netter contract with a batch/window/dispute lifecycle (open_batch, submit_txn, net, finalize, raise_dispute). That contract was never built; the core netting invariant it was meant to enforce was instead implemented directly on agent_vault:
settle_net(entries: Vec<(Address, i128)>)— operator-only. Entries are(agent, delta)pairs; deltas must sum to zero (float conserved across the batch), each resulting float is re-checked against the solvency invariant, batch size is bounded (BATCH_MAX).- What's not built: the separate batch-lifecycle wrapper (
open_batch/window_end/raise_disputeheld on-chain pending adjudication). Loan-level disputes are handled off-chain today viaadminDisputeController.ts/ theloan_disputestable — there is no on-chain settlement-dispute hold.
If an on-chain, windowed settlement-dispute mechanism is needed later, it would be new scope, not a revival of the original settlement-netter design as originally spec'd.
Cash-in: Customer cash → Agent
→ agent signs CashInEvent (backend verifies, rate-limited)
→ backend calls AgentVault.mint_float + issues USDC to customer wallet
→ event → indexer → Postgres → dashboard shows float/collateral live
Settlement: backend collects txns in time-window
→ SettlementNetter.submit_txn (signed)
→ net() computes net positions
→ finalize() atomically moves float between vaults
→ event → indexer → audit API
Dispute: any party raises dispute within window
→ position held in netter
→ operator adjudicates off-chain, resolves on-chain
- Instance storage: global params, operator, owner.
- Persistent storage: per-agent/per-vault records keyed by
Address.
- backend/ (Express 5, TS): auth (JWT), onboarding + KYC adapter, transaction API (cash-in/out), settlement service, rate limiting, zod validation at the boundary. Postgres via
node-pg-migrate; Redis cache; Sentry; prom-client metrics. - indexer/ (Rust → Postgres): consumes contract events, materializes audit queries and live dashboards. (P2)
- frontend/ (Next.js 16): agent dashboard (float, collateral, status), admin console, find-an-agent map.
- Access control reviewed on every contract function: owner / operator / agent.
- No unsigned state writes:
submit_txnis operator-signed. - Input validation at every API boundary (zod);
express-rate-limiton all endpoints. - Secrets only in env; non-root containers; no hardcoded keys.
- Solvency invariant (
Σ float ≤ Σ collateral) is property-tested.
- Staging via GitHub Actions;
docker-composefor local/staging parity. - Sentry (frontend + backend), pino structured logs, prom-client metrics (txn throughput, batch latency, float ratio), health endpoints.