Skip to content

CreditScore.sol

UUPS-upgradeable on-chain credit scoring. Tracks farmer repayment history, delivery consistency, and cooperative standing. Portable, permanent, and public — any MFI can query it before approving a loan.

EventScore changeConstantNotes
Account created+500STARTING_SCOREStarting score for every farmer
On-time repayment+40REPAYMENT_BONUSPer loan cycle — financial obligation met
On-time batch delivery+15DELIVERY_BONUSPer delivery, even without a loan
Default (after forbearance)−100DEFAULT_PENALTYFarmer blocked from new loans until score recovers
Cooperative penaltyvariableparameterAdmin-applied per farmer — see Cooperative Penalty section
Score floor0SCORE_FLOORClamped — never reverts
Score ceiling850SCORE_CEILINGClamped — never reverts

:::note Why repayment (+40) outweighs delivery (+15) Repayment proves a financial obligation was met — the farmer borrowed capital and returned it on time. Delivery confirms product exists but requires no loan. The asymmetry is intentional: credit history should be harder to build through delivery volume alone than through actual loan performance. :::

The cooperative penalty is not applied in a single on-chain transaction. Iterating over all members of a cooperative on-chain is an unbounded loop — a known gas DoS anti-pattern. Instead:

  1. An off-chain admin script queries FarmerRegistry (or a subgraph) to get all farmerWallet addresses for a given cooperative.
  2. The script calls recordPenalty(farmerWallet, penaltyAmount) once per farmer, where penaltyAmount is set by DEFAULT_ADMIN_ROLE based on the severity of the cooperative-level event.
  3. Each call emits ScoreUpdated — creating a permanent on-chain audit trail per farmer.
// Called by DEFAULT_ADMIN_ROLE for each affected farmer
function recordPenalty(address farmerWallet, uint256 penaltyAmount)
external onlyRole(DEFAULT_ADMIN_ROLE);

:::caution Penalty amount is not hardcoded Unlike repayment and delivery bonuses, the penalty amount is passed as a parameter. Cooperative-level events vary in severity — a late payment obligation is not the same as fraud. DEFAULT_ADMIN_ROLE determines the appropriate penalty per event. :::

As credit score improves, farmers access higher LTV and larger loan ceilings:

Score rangeLTV tierMax loan (USDC)Notes
500–549Standard$200Starting tier
550–649Enhanced$500After 1–2 successful repayments
650–749Premium$1,500After 3–5 successful repayments
750–850Institutional$5,000Cooperative-level loans available
// Get current score — public, any MFI can call
// Returns STARTING_SCORE (500) for farmers not yet initialised
function getScore(address farmerWallet) external view returns (uint256);
// Get LTV tier — used by LendingVault.originate()
// maxLoanUsdc is 6-decimal USDC (200_000000 = $200 USDC)
function getLtvTier(address farmerWallet)
external view returns (uint256 maxLoanUsdc, uint256 ltvBasisPoints);
// Record successful repayment — VAULT_ROLE only
// Called by LendingVault when loan reaches SETTLED state
function recordRepayment(address farmerWallet)
external onlyRole(VAULT_ROLE);
// Record batch delivery — AGENT_ROLE only
// Called by API on DELIVERED stage confirmation
function recordDelivery(address farmerWallet)
external onlyRole(AGENT_ROLE);
// Record default — VAULT_ROLE only
// Called by LendingVault after forbearance period expires
function recordDefault(address farmerWallet)
external onlyRole(VAULT_ROLE);
// Apply admin penalty to one farmer — DEFAULT_ADMIN_ROLE only
// Used for cooperative-level penalties applied per farmer off-chain
function recordPenalty(address farmerWallet, uint256 penaltyAmount)
external onlyRole(DEFAULT_ADMIN_ROLE);

Existence check for all state-changing functions:

require(
farmerRegistry.farmerExists(farmerWallet),
"CreditScore: farmer not registered"
);

farmerExists() returns true for both active and deactivated farmers. Only returns false for addresses never registered in FarmerRegistry.

Zero-floor ambiguity — the initialized mapping

Section titled “Zero-floor ambiguity — the initialized mapping”

scores[farmerWallet] == 0 is ambiguous in Solidity: it could mean the farmer has never interacted with the contract, OR their score has legitimately been driven to the floor by defaults. Using 0 as a sentinel value for “uninitialised” breaks when 0 is also a valid state.

The contract resolves this with a separate initialized boolean mapping:

mapping(address => bool) private initialized;
function getScore(address farmerWallet) public view returns (uint256) {
if (!initialized[farmerWallet]) return STARTING_SCORE;
return scores[farmerWallet];
}

On the first state-changing call for a farmer, _applyScoreChange sets initialized[farmerWallet] = true and emits ScoreInitialised before applying the delta. Subsequent calls skip initialisation. This means a farmer at floor (score = 0) correctly returns 0 from getScore, not 500.

Keeping scores public has genuine privacy tradeoffs. The design rationale:

  • Scores are linked to MAAIF farmer IDs (government-issued) — not to phone numbers or names
  • A government ID being associated with creditworthiness is the same as a credit bureau report — standard in formal finance
  • The alternative (private scores) requires AsiliChain to act as a credit bureau with associated regulatory obligations
  • Public scores allow multiple MFIs to compete for the same farmer’s loan — which drives rates down, benefiting the farmer
  • Farmers can build score history and take it to any future lender — true financial inclusion

A farmer’s CreditScore follows their wallet address — the same primary key used across all AsiliChain contracts. If a farmer moves to a different cooperative, their score persists unchanged. Their wallet address never changes; only their cooperativeWallet field in FarmerRegistry is updated via migrateFarmer().

For integrators who need to look up a score by MAAIF ID rather than wallet address, use the maaifToWallet reverse lookup in FarmerRegistry first:

address wallet = farmerRegistry.maaifToWallet(maaifId);
uint256 score = creditScore.getScore(wallet);
// Primary key: wallet address (consistent with FarmerRegistry)
mapping(address => uint256) public scores; // wallet → current score
mapping(address => uint256) public lastUpdated; // wallet → last update timestamp
// Score history is NOT stored on-chain.
// Reconstruct from ScoreUpdated events via subgraph or RPC log query.
// address of FarmerRegistry — set at initialize(), used for existence checks
IFarmerRegistry public farmerRegistry;
```solidity
// Primary key: wallet address (consistent with FarmerRegistry)
mapping(address => uint256) public scores; // wallet → current score
mapping(address => uint256) public lastUpdated; // wallet → last update timestamp
// Score history is NOT stored on-chain.
// Reconstruct from ScoreUpdated events via subgraph or RPC log query.
// address of FarmerRegistry — set at initialize(), used for existence checks
IFarmerRegistry public farmerRegistry;