Backend for Kimana, implementing the typed ApiClient contract that
Kimana_frontend already defined and runs against. Full plan and rationale:
docs/backend-plan.md (not tracked).
Status: P1 + P2. Auth session, the onboarding wizard, the dashboard
overview, indicative FX, recipients, firm quotes, and the full transfer
lifecycle (state machine + ledger postings) are served for real. Still ahead:
trade documents, customer screening view, all /ops.
Rust · axum 0.8 · sqlx 0.8 (Postgres, runtime queries, no ORM) · tokio.
Plain SQL migrations in migrations/. Money is always integer minor units
(i64) + a currency code. ledger_entries and audit_log are append-only
(DB triggers).
cp .env.example .env
docker compose up -d # Postgres on :5432
cargo run --bin migrate # apply migrations/*.sql
cargo run --bin seed # demo customer "Chinonso" / Adunola Exports Ltd
cargo run # http://localhost:4000 (also runs migrations)cargo test # integration suite (needs Postgres up)
cargo build --release
cargo clippy --all-targetsTests share one database and reseed per test, serialised with a
cross-process file lock (serial_test), so cargo test is safe to run as-is.
| Method | Path | |
|---|---|---|
| GET | /health |
unauthenticated |
| GET | /session |
seeded demo session |
| GET | /onboarding/application |
|
| PUT | /onboarding/application/business |
{ business, applicationId? } |
| PUT | /onboarding/application/principals |
{ principals, applicationId? } |
| POST | /onboarding/application/documents |
multipart: type, file |
| POST | /onboarding/application/documents/{id}/retry |
|
| DELETE | /onboarding/application/documents/{id} |
204 |
| POST | /onboarding/application/submit |
walks draft→submitted→in_review, runs the KYB provider, lands on approved or rejected |
| GET | /dashboard/overview |
balances from ledger_entries; in-progress count + USD 30d volume from transfers |
| GET | /rates/indicative?send=&receive= |
drifting stub rate |
| GET/POST | /recipients · POST /recipients/validate |
|
| POST | /quotes |
firm quote, expiresAt = issuedAt + 90s |
| POST | /transfers |
idempotent (Idempotency-Key header or body); parks at AWAITING_FUNDS |
| GET | /transfers · /transfers/{id} · /transfers/{id}/timeline |
?status= filter on the list |
| GET | /settlement/balance · /settlement/transactions |
USDC wallet; see Settlement wallet |
| POST | /settlement/buy · /settlement/convert |
idempotent, like /transfers |
| POST | /collections |
payment request with pay-in instructions; idempotent, like /transfers |
| GET | /collections · /collections/{id} · POST /collections/{id}/cancel |
see Collections |
| GET | /receiving-accounts |
the customer's standing USD account, once linked |
| POST | /webhooks/yellowcard · /webhooks/bridge |
partner-signed; credit inbound money |
Errors are { code, message, retryable } (SCREAMING_SNAKE codes) with the
status mapping in docs/backend-plan.md §02.
abi/ SettlementVault ABI, copied from kimana_contract
migrations/ forward-only SQL, embedded via sqlx::migrate!
src/
main.rs server entrypoint (migrate + serve)
bin/{migrate,seed}.rs
lib.rs module tree + build_app(state) -> Router
config.rs error.rs db.rs
http/ Session extractor (FromRequestParts), Body<T> JSON extractor
audit.rs write_audit (same-transaction append)
storage.rs filesystem document store (MinIO/S3 later)
contract/ serde structs mirroring Kimana_frontend/src/api/types
domain/
onboarding/ schema · repo · service · routes · kyb (stub provider)
dashboard.rs
fx.rs indicative rates (stub jitters a seeded rate)
recipients.rs list / validate (name-resolver stub) / save
quote.rs firm quotes
transfers/ service · repo · state_machine · engine · routes
ledger.rs balance reads · post_entry (running balance under a lock)
settlement/ SettlementVault: bindings · units · client · error · listener (confirmed events → transfer states)
seed.rs demo tenant, mirrors the frontend mock store
tests/ integration tests — tower::oneshot against the real Router + Postgres
integration/ drop-in live client + wiring notes for Kimana_frontend
scripts/ settlement-e2e.sh: Anvil + LocalE2E.s.sol + tests/settlement_anvil.rs
src/contract/ is a hand-maintained mirror of Kimana_frontend/src/api/types.
Rust and TypeScript don't share a compiler, so keeping serde field names
(#[serde(rename_all = "camelCase")]) and enum reprs faithful to the TS
contract is a review discipline, not a compile-time guarantee. A future
step is to generate these from the TS types (or an OpenAPI spec) — see
docs/backend-plan.md §00.
submit moves the application through submitted → in_review, runs
kyb::run_checks, then commits approved (+ approvedSummary) or rejected
(+ rejectionReasons). Per-check results land in kyb_checks. It resolves
only at a terminal status — the frontend does not poll.
The provider is a stub (src/domain/onboarding/kyb.rs): everything passes
unless the data trips a documented trigger — legal name containing reject
(CAC), a principal BVN of 00000000000 (NIBSS), a principal name containing
sanction. KYB_CHECK_DELAY_MS tunes the simulated latency (0 in tests).
The frontend has no
rejectedscreen yet —VerificationPagenavigates to/onboarding/approvedon any resolved submit. Building that screen is frontend work.
create_transfer snapshots the quote (rejecting an expired one with
RATE_EXPIRED), then runs the internal checks inline and parks the transfer at
AWAITING_FUNDS with a funding reference. TRANSFER_AUTO_ADVANCE_MS later
spawns a task that simulates the collection-partner "funds received" webhook
plus the settlement/payout pipeline, driving it to COMPLETED (-1 disables;
tests call the engine directly).
src/domain/transfers/state_machine.rs holds the explicit transition table —
every change goes through assert_transition (illegal → CONFLICT).
engine.rs applies one transition per transaction (history row + audit +
ledger postings together).
Ledger model (customer-account-centric FX-through payment; running balance computed under an account row lock):
| Transition | Posting |
|---|---|
→ FUNDED |
-sendAmount from the send-currency account (rejects INSUFFICIENT_FUNDS if short) |
→ SETTLED |
+receiveAmount to the receive-currency account |
→ COMPLETED |
-receiveAmount from the receive-currency account (paid to the beneficiary) |
→ REVERSING from SETTLED/PAYING_OUT |
-receiveAmount from the receive-currency account (payout failed on-chain) |
→ REVERSED |
+sendAmount back to the send account, linked via reversal_of_entry_id |
src/settlement/ calls the SettlementVault from
kimana_contract with alloy.
Bindings come from abi/SettlementVault.json, a copy of the contract repo's
file: copy it again whenever the contract's ABI changes.
units:transfer_ref,quote_id,cents_to_usdc/usdc_to_cents,RateE8(8-decimal integer rate, parsed from a decimal string) andreceive_amount_minor. Integer only, and the parity tests pin them to the contract's own test vectors.client:lock_quote,settle,cancel_quote,refund. Each waits for the receipt.lock_quotederives the counterparty amount with the vault's formula and refuses to send an already-expired quote.error: vault reverts decoded and mapped to API errors. An expired quote isRATE_EXPIRED; reuse or a wrong lifecycle state isCONFLICT; a blocked divergent rate or a paused vault isPARTNER_FAILURE; a limit breach isCOMPLIANCE_HOLD; a quote the vault rejects as malformed is a loggedSERVER_ERROR.
Config: SETTLEMENT_RPC_URL and SETTLEMENT_VAULT_ADDRESS. The testnet
vault is on Base Sepolia (chain 84532) at
0x89c1EE7c7888154Fb82e868f36bA5Dd3d4D80Faf, deployed at block 47240140;
the values are in .env.example. The operator
signer is passed to SettlementClient::new and never read from the
environment.
settlement::listener runs in the server process when settlement is
configured. It reads vault events at least SETTLEMENT_CONFIRMATIONS blocks
deep and applies them to the transfer whose settlement_ref matches:
| Event | Backend effect |
|---|---|
QuoteLocked |
recorded + audited; status unchanged (the transfer is past QUOTED by then) |
SettlementInitiated |
SETTLING → SETTLED |
SettlementReturned |
→ REVERSING, with -receiveAmount undoing the conversion |
SettlementRefunded |
→ REVERSED, with +sendAmount back to the customer |
QuoteCancelled |
→ EXPIRED if the locked quote had expired, else REJECTED |
RateDivergence, ReferenceRateStale |
recorded on the transfer + audited |
- Idempotent: each log is one
settlement_eventsrow, keyed by(tx_hash, log_index)and written in the same transaction as its transition, so a replayed log is skipped. - Resumable:
settlement_cursorstores the last processed block; a restart resumes after it (SETTLEMENT_START_BLOCKfor the first run). Logs are readSETTLEMENT_LOG_RANGEblocks at a time (default 1,000, the cap onsepolia.base.org). - Reorgs: only confirmed blocks are read. If the cursor block's hash changes (a reorg deeper than the confirmation depth), the listener stops and logs an error for ops instead of rewriting the append-only ledger.
- While it is on, the simulator stops at
SETTLING: only the chain settles.
Backend-driven lock, settle, cancel and refund, plus the listener's happy,
refund, cancel, alert, restart and reorg cases, against the contract's
make e2e Anvil setup (needs Foundry, make install in the contract repo,
and Postgres):
KIMANA_CONTRACT_DIR=../kimana_contract bash scripts/settlement-e2e.shNot wired into the transfer lifecycle yet: quotes.rate is still stored as
f64, and moving it to RateE8 is its own change.
src/domain/settlement_wallet.rs is the one customer surface that names
USDC (issue #77, a scoped exception to the hidden-crypto rule). The
SettlementVault holds pooled USDC and only moves it to or from partners, so
there is no per-customer balance on-chain: the wallet is a ledger account
with currency USDC, in cents like USD, and ledger::get_balances leaves it
out so the dashboard never shows it.
POST /settlement/buy{ amount: Money }debits the local account and credits USDC.POST /settlement/convert{ usdcAmountMinor, currency }does the reverse. Both use the indicativeUSD/<ccy>rate (USDC = USD 1:1, and exactly 1 for USD), floor in both directions, reject an overdraft, take an idempotency key, and write onesettlement_tradesrow, two ledger legs and anaudit_logrow in one transaction.GET /settlement/transactionslists the trades, newest first.GET /settlement/balanceadds anetworkblock (chain id, vault, USDC token, explorer) whenSETTLEMENT_CHAIN_IDandSETTLEMENT_VAULT_ADDRESSare set.
No trade calls the vault. USDC reaches or leaves the pool only through the
partner flows (fund, settle) in the transfer lifecycle. Sending USDC to a
customer's own address would need a contract change.
src/domain/collections.rs is the receive side (issue #79): the customer is
the payee. POST /collections { amount: Money, payerName?, note?, expiresAt? } creates a payment request with a reference (CL-XXXXXX) and a
payIn block telling the payer where to send the money. The currency picks
the partner (src/partners/):
- NGN goes through Yellow Card. Each request opens its own force-accepted
bank-transfer receive (
sequenceId= collection id), andpayInis that receive's bank account. The account only takes money while the receive is open, so the request'sexpiresAtis capped at the receive's (about 30 minutes in Yellow Card's docs). Cancelling a request cancels the receive first; if Yellow Card refuses, the request stays open.POST /webhooks/yellowcardchecksX-YC-Signature(base64 HMAC-SHA256 of the raw body under the API secret), then fetches the receive back. It creditsconvertedAmountin NGN once the status iscomplete. - USD goes through Bridge. Each customer has one standing US virtual
account (ACH or wire), delivering USDC to
BRIDGE_DESTINATION_ADDRESS, or to the settlement vault by default. Ops links it after the customer has passed Bridge's own KYB:cargo run --bin link-bridge -- <customer-id> <bridge-customer-id>. This is a CLI because there is no operator role to guard a route with yet.GET /receiving-accountsshows the account.POST /webhooks/bridgechecksX-Webhook-Signature(RSA overSHA-256("<t>.<body>")with the endpoint's public key, refused past 10 minutes) and credits everypayment_processeddeposit in USD, flooring to the cent. A deposit whose memo quotes an open request's reference (with or without the hyphen) also marks that request paid.
Money a partner confirms is always credited, even when it differs from the
request or arrives after it closed. The request's payment block shows what
actually arrived. A late Yellow Card payment still marks its request paid,
because only that payer could have used the account. A late Bridge deposit
does not. Each payment is one inbound_payments row (keyed by the partner's
id, so redeliveries are no-ops), one ledger credit pointing at it, and an
audit_log row, all in one transaction.
Status is PENDING, then PAID or CANCELLED. EXPIRED isn't stored: a
pending request past expiresAt reads as expired.
Open questions: who bears partner fees (credits are what the partner reports
as received), and whether Bridge's amount or subtotal_amount is the net
delivered figure. Check both against sandbox deposits before going live.
See integration/README.md — copy one file into Kimana_frontend, flip
src/api/index.ts, set NEXT_PUBLIC_API_URL. The wire contract (HTTP/JSON) is
identical regardless of backend language.
- Auth is a single seeded session; no login yet.
src/contractis a hand-maintained mirror, not generated from the TS types.- KYB + transfer progression run in-process; a restart between
AWAITING_FUNDSandCOMPLETEDleaves the transfer parked (no resume sweep). - Screening always clears (
hold: false) — the ops decision path is P3/P4. payoutSuccessRatePercent/avgSettlementSecondson the dashboard are still placeholder.