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.
Score Mechanics
Section titled “Score Mechanics”| Event | Score change | Constant | Notes |
|---|---|---|---|
| Account created | +500 | STARTING_SCORE | Starting score for every farmer |
| On-time repayment | +40 | REPAYMENT_BONUS | Per loan cycle — financial obligation met |
| On-time batch delivery | +15 | DELIVERY_BONUS | Per delivery, even without a loan |
| Default (after forbearance) | −100 | DEFAULT_PENALTY | Farmer blocked from new loans until score recovers |
| Cooperative penalty | variable | parameter | Admin-applied per farmer — see Cooperative Penalty section |
| Score floor | 0 | SCORE_FLOOR | Clamped — never reverts |
| Score ceiling | 850 | SCORE_CEILING | Clamped — 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. :::
Cooperative Penalty
Section titled “Cooperative Penalty”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:
- An off-chain admin script queries FarmerRegistry (or a subgraph)
to get all
farmerWalletaddresses for a given cooperative. - The script calls
recordPenalty(farmerWallet, penaltyAmount)once per farmer, wherepenaltyAmountis set byDEFAULT_ADMIN_ROLEbased on the severity of the cooperative-level event. - Each call emits
ScoreUpdated— creating a permanent on-chain audit trail per farmer.
// Called by DEFAULT_ADMIN_ROLE for each affected farmerfunction 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. :::
Score-to-LTV Tiers
Section titled “Score-to-LTV Tiers”As credit score improves, farmers access higher LTV and larger loan ceilings:
| Score range | LTV tier | Max loan (USDC) | Notes |
|---|---|---|---|
| 500–549 | Standard | $200 | Starting tier |
| 550–649 | Enhanced | $500 | After 1–2 successful repayments |
| 650–749 | Premium | $1,500 | After 3–5 successful repayments |
| 750–850 | Institutional | $5,000 | Cooperative-level loans available |
Interface
Section titled “Interface”// Get current score — public, any MFI can call// Returns STARTING_SCORE (500) for farmers not yet initialisedfunction 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 statefunction recordRepayment(address farmerWallet) external onlyRole(VAULT_ROLE);
// Record batch delivery — AGENT_ROLE only// Called by API on DELIVERED stage confirmationfunction recordDelivery(address farmerWallet) external onlyRole(AGENT_ROLE);
// Record default — VAULT_ROLE only// Called by LendingVault after forbearance period expiresfunction 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-chainfunction 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.
Implementation Notes
Section titled “Implementation Notes”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.
Why Public Scores Are Correct
Section titled “Why Public Scores Are Correct”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
Portability
Section titled “Portability”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);Storage
Section titled “Storage”// Primary key: wallet address (consistent with FarmerRegistry)mapping(address => uint256) public scores; // wallet → current scoremapping(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 checksIFarmerRegistry public farmerRegistry;```solidity// Primary key: wallet address (consistent with FarmerRegistry)mapping(address => uint256) public scores; // wallet → current scoremapping(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 checksIFarmerRegistry public farmerRegistry;