Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Kimana_backend

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.

Stack

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).

Quickstart

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-targets

Tests 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.

Endpoints

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.

Layout

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

Contract fidelity

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.

KYB (onboarding submit)

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 rejected screen yet — VerificationPage navigates to /onboarding/approved on any resolved submit. Building that screen is frontend work.

Transfer lifecycle

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

On-chain settlement

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) and receive_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_quote derives 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 is RATE_EXPIRED; reuse or a wrong lifecycle state is CONFLICT; a blocked divergent rate or a paused vault is PARTNER_FAILURE; a limit breach is COMPLIANCE_HOLD; a quote the vault rejects as malformed is a logged SERVER_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.

Event listener

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_events row, keyed by (tx_hash, log_index) and written in the same transaction as its transition, so a replayed log is skipped.
  • Resumable: settlement_cursor stores the last processed block; a restart resumes after it (SETTLEMENT_START_BLOCK for the first run). Logs are read SETTLEMENT_LOG_RANGE blocks at a time (default 1,000, the cap on sepolia.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.sh

Not wired into the transfer lifecycle yet: quotes.rate is still stored as f64, and moving it to RateE8 is its own change.

Settlement wallet

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 indicative USD/<ccy> rate (USDC = USD 1:1, and exactly 1 for USD), floor in both directions, reject an overdraft, take an idempotency key, and write one settlement_trades row, two ledger legs and an audit_log row in one transaction.
  • GET /settlement/transactions lists the trades, newest first.
  • GET /settlement/balance adds a network block (chain id, vault, USDC token, explorer) when SETTLEMENT_CHAIN_ID and SETTLEMENT_VAULT_ADDRESS are 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.

Collections

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), and payIn is that receive's bank account. The account only takes money while the receive is open, so the request's expiresAt is 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/yellowcard checks X-YC-Signature (base64 HMAC-SHA256 of the raw body under the API secret), then fetches the receive back. It credits convertedAmount in NGN once the status is complete.
  • 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-accounts shows the account. POST /webhooks/bridge checks X-Webhook-Signature (RSA over SHA-256("<t>.<body>") with the endpoint's public key, refused past 10 minutes) and credits every payment_processed deposit 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.

Connecting the frontend

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.

Known gaps (tracked in docs/backend-plan.md)

  • Auth is a single seeded session; no login yet.
  • src/contract is a hand-maintained mirror, not generated from the TS types.
  • KYB + transfer progression run in-process; a restart between AWAITING_FUNDS and COMPLETED leaves the transfer parked (no resume sweep).
  • Screening always clears (hold: false) — the ops decision path is P3/P4.
  • payoutSuccessRatePercent / avgSettlementSeconds on the dashboard are still placeholder.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages