This document describes the collateral scoring model used in the Mainstay lifecycle contract to assess the value and eligibility of assets based on their maintenance history.
The collateral scoring system is designed to provide a quantitative measure of an asset's maintenance quality and recency. Higher scores indicate well-maintained assets that are more suitable for use as collateral in financial transactions.
Assets with at least one verified maintenance record are guaranteed a minimum score of 1 (MIN_SCORE_WITH_HISTORY), even if time-based decay would otherwise reduce the raw score to 0.
This prevents a legitimately-maintained asset from becoming indistinguishable from one with no history at all.
| Maintenance records | Minimum score returned |
|---|---|
| 0 | 0 |
| ≥ 1 | 1 (floor) |
With default configuration (score_increment = 5):
- 1 record → raw score = 5 (non-zero immediately after submission)
- After sufficient decay the raw score reaches 0, but the floor ensures
get_collateral_scorereturns 1
To reach the default eligibility threshold of 50, an asset needs at least 10 records at the default increment of 5 points each (or fewer records using higher-weight task types such as ENGINE / OVERHAUL at 10 points each).
- Minimum Score: 0 points (no maintenance history), or 1 point (at least one record — see Score Floor)
- Maximum Score: 100 points
- Eligibility Threshold: 50 points (default, configurable)
Scores are calculated based on:
- Maintenance Task Weight: Different task types add different point values
- Time-Based Decay: Scores decrease over time without maintenance
- Score Cap: Total score never exceeds 100 points
The Lifecycle contract computes collateral score using a combination of weighted maintenance events and time decay:
raw_score = sum(weight(task_i) * recency_weight(task_i))
decay = floor((current_timestamp - last_update) / decay_interval) * decay_rate
score = clamp(max(raw_score - decay, 0), 0, 100)
return if has_history && score == 0 { 1 } else { score }
Maintenance history --> Weighted event points --> Time decay --> Score cap (100) --> Floor clamp (has_history ? max(score,1) : score)
task weights recency calc decay rate clamp to [0,100] MIN_SCORE_WITH_HISTORY = 1
The floor clamp is the last step: it only raises a score that decayed to 0, and only when the asset has at least one maintenance record. It never affects assets that were never maintained (those stay at 0), and it never lowers a score that decay left above 0. See Score Floor for the rationale.
The eligibility threshold is checked after this score is computed: the asset is collateral-eligible only if the final score is greater than or equal to the configured threshold.
Lender note: because the floor guarantees a minimum score of 1 for any asset with maintenance history, an
eligibility_thresholdof 1 is effectively "any maintained asset passes" — it does not distinguish between a freshly-serviced asset and one whose score has fully decayed. See Eligibility Threshold for how this interacts with threshold configuration.
Maintenance tasks are categorized into three tiers with different point values:
- OIL_CHG - Oil changes
- LUBE - Lubrication services
- INSPECT - General inspections
- FILTER - Filter replacements
- TUNE_UP - Engine tuning
- BRAKE - Brake system maintenance
- ENGINE - Engine work/rebuilds
- OVERHAUL - Complete overhauls
- REBUILD - Major rebuilds
- Default Weight: 3 points
- Applied to any task type not explicitly categorized
| Task type | Weight | Rationale |
|---|---|---|
OIL_CHG |
2 | Routine consumable service; low cost, frequent cadence, minimal impact on asset longevity per event. |
LUBE |
2 | Preventive, low-cost, high-frequency task with a small marginal effect on collateral value. |
INSPECT |
2 | Diagnostic only — verifies condition but does not itself extend asset life, so it is weighted like other minor tasks. |
FILTER |
5 | Protects core components (engine/fuel system) from contamination; moderate cost and moderate impact on reliability. |
TUNE_UP |
5 | Restores performance and efficiency; broader scope than a single-part swap, so weighted above minor tasks. |
BRAKE |
5 | Safety-critical system service; weighted at the medium tier to reflect both cost and risk mitigation. |
ENGINE |
10 | High cost, directly extends the useful life of the highest-value component on most DePIN hardware. |
OVERHAUL |
10 | Comprehensive multi-system rebuild; the strongest signal of long-term asset care available. |
REBUILD |
10 | Equivalent scope/cost to an overhaul — restores the asset close to as-new condition. |
| (unrecognized) | 3 | Default for task types outside the allowlist; deliberately between minor and medium so unknown work is never over- or under-credited. |
A minimal example isolating just the decay/score interaction, using the documented defaults (decay_rate = 5, decay_interval = 30 days, score_increment via task weights):
| # | Day | Task | Weight | Days since prior | Decay before event | Score after decay | Score after event |
|---|---|---|---|---|---|---|---|
| — | 0 (registration) | — | — | — | — | — | 0 |
| 1 | 0 | ENGINE |
10 | 0 | 0 | 0 | 10 |
| 2 | 18 | FILTER |
5 | 18 | floor(18/30)*5 = 0 |
10 | 15 |
| 3 | 40 | BRAKE |
5 | 22 | floor(22/30)*5 = 0 |
15 | 20 |
| 4 | 65 | OVERHAUL |
10 | 25 | floor(25/30)*5 = 0 |
20 | 30 |
| 5 | 90 | TUNE_UP |
5 | 25 | floor(25/30)*5 = 0 |
30 | 35 |
At registration (day 0, before any record) the score is 0 — no maintenance history yet. After the 5th record on day 90, the score is 35.
After 60 further days of inactivity (day 150, no new records):
elapsed_time = 60 days
decay_intervals = floor(60 / 30) = 2
total_decay = 2 * 5 = 10
new_score = max(35 - 10, 0) = 25
Score at day 150: 25 (still above the score floor of 1 since the asset has maintenance history, and below the default eligibility threshold of 50 — this asset would not be collateral-eligible at that point).
- Decay Rate: 5 points per interval
- Decay Interval: 2,592,000 seconds (30 days)
- Effective Decay: 5 points per 30 days without maintenance
decay_intervals = time_elapsed / decay_interval
total_decay = decay_intervals * decay_rate
new_score = max(0, current_score - total_decay)
- Asset score: 60 points
- Time since last maintenance: 60 days
- Decay intervals: 60 / 30 = 2
- Total decay: 2 * 5 = 10 points
- New score: 60 - 10 = 50 points
The system maintains a complete history of score changes:
- Entry Format:
(timestamp, score)tuple - Trigger: Recorded after each maintenance event
- Purpose: Enables trend analysis and audit trails
- Required Score: 50 points
- Purpose: Minimum maintenance quality for collateral consideration
is_eligible = current_score >= eligibility_threshold
- Loan Collateral: Assets meeting threshold can secure financing
- Insurance Premiums: Higher scores may reduce insurance costs
- Asset Valuation: Score correlates with market value retention
All scoring parameters are configurable by contract administrators:
- Purpose: Base points added per maintenance task
- Default: Not used (task weights take precedence)
- Validation: Must be > 0
- Decay Rate: Points deducted per interval
- Decay Interval: Time between decay calculations (seconds)
- Validation: Interval must be > 0
- Purpose: Minimum score for collateral eligibility
- Default: 50 points
- Range: 0-100 points
- Score floor interaction: Because every asset with at least one maintenance record is guaranteed a
score of at least
1(see Score Floor), setting this threshold to1means any maintained asset is eligible, regardless of how stale its maintenance history is. Lenders wanting to actually gate on maintenance quality/recency should set the threshold well aboveMIN_SCORE_WITH_HISTORY(the default of 50 is intended for this purpose).
- Default Limit: 200 records per asset
- Purpose: Prevents unlimited storage growth
- Configurable: Can be adjusted by administrators
- Supported: Yes
- Parameters:
offset(start index),limit(max records) - Use Case: UI display of large histories
The table below walks through a realistic generator maintenance schedule over roughly 12 months using the documented defaults:
decay_rate = 5decay_interval = 30 dayseligibility_threshold = 50- task weights: minor = 2, medium = 5, major = 10
Assumption: the score is checked immediately before each maintenance event, so any elapsed-time decay is applied first and the new maintenance points are then added.
| Event | Approx. date | Task | Weight | Days since prior event | Decay applied before event | Score after decay | Score after event |
|---|---|---|---|---|---|---|---|
| 1 | Jan 1 | ENGINE |
10 | — | 0 | 0 | 10 |
| 2 | Jan 21 | FILTER |
5 | 20 | floor(20 / 30) * 5 = 0 |
10 | 15 |
| 3 | Feb 20 | BRAKE |
5 | 30 | floor(30 / 30) * 5 = 5 |
10 | 15 |
| 4 | Mar 12 | OVERHAUL |
10 | 20 | floor(20 / 30) * 5 = 0 |
15 | 25 |
| 5 | Apr 11 | FILTER |
5 | 30 | floor(30 / 30) * 5 = 5 |
20 | 25 |
| 6 | May 1 | TUNE_UP |
5 | 20 | floor(20 / 30) * 5 = 0 |
25 | 30 |
| 7 | May 31 | ENGINE |
10 | 30 | floor(30 / 30) * 5 = 5 |
25 | 35 |
| 8 | Jun 20 | FILTER |
5 | 20 | floor(20 / 30) * 5 = 0 |
35 | 40 |
| 9 | Jul 20 | BRAKE |
5 | 30 | floor(30 / 30) * 5 = 5 |
35 | 40 |
| 10 | Aug 19 | OVERHAUL |
10 | 30 | floor(30 / 30) * 5 = 5 |
35 | 45 |
| 11 | Sep 8 | FILTER |
5 | 20 | floor(20 / 30) * 5 = 0 |
45 | 50 |
| 12 | Oct 8 | REBUILD |
10 | 30 | floor(30 / 30) * 5 = 5 |
45 | 55 |
- The first major service starts the generator at 10.
- Short 20-day gaps do not trigger decay because decay uses whole 30-day intervals.
- Each 30-day gap removes 5 points before the next event is applied.
- Repeated medium and major maintenance steadily offsets decay and pushes the score upward.
- After event 11, the generator reaches the default eligibility threshold exactly: 50.
- After event 12, the score rises to 55, so the asset is above the threshold.
Using the default rule:
is_eligible = current_score >= 50
Immediately after the final REBUILD event:
current_score = 55
eligibility_threshold = 50
55 >= 50 => eligible
If no new maintenance is recorded for the next 60 days, decay is applied again:
elapsed_time = 60 days
decay_intervals = floor(60 / 30) = 2
total_decay = 2 * 5 = 10
new_score = 55 - 10 = 45
45 >= 50 => not eligible
This illustrates the intended behavior of the model:
- maintenance events increase the score,
- inactivity causes the score to decay in discrete steps, and
- collateral eligibility depends on the score at the time of the check, not just on historical maintenance volume.
Initial Score: 0
+ Oil Change (2 points): Score = 2
+ Filter Replacement (5 points): Score = 7
+ Engine Overhaul (10 points): Score = 17
Initial Score: 80
Time since last maintenance: 90 days
Decay intervals: 90 / 30 = 3
Total decay: 3 * 5 = 15 points
Final Score: 80 - 15 = 65 points
Current Score: 95
+ Major Rebuild (10 points): Score = 100 (capped at maximum)
- Verification: Asset existence validated before scoring
- Events: Score changes emit maintenance events
- Verification: Only verified engineers can submit maintenance
- Authorization: Ensures quality of maintenance records
- Regular Maintenance: Prevents score decay
- Major Tasks: Prioritize high-weight maintenance
- Documentation: Keep detailed maintenance records
- Threshold Monitoring: Set appropriate eligibility levels
- Score Trends: Analyze maintenance quality over time
- Risk Assessment: Use scores as part of comprehensive risk models
- Task Classification: Use appropriate task types
- Timely Updates: Submit maintenance promptly
- Quality Notes: Provide detailed maintenance information
- Score:
("SCORE", asset_id) - Score History:
("SCHIST", asset_id) - Last Update:
("LUPD", asset_id)
- Extension: All score-related entries extend TTL on updates
- Duration: 518,400 seconds (~6 days)
- Purpose: Prevents data loss
- Dynamic Weights: Task weights based on asset type
- Quality Factors: Multiplier based on engineer certification level
- Seasonal Adjustments: Different decay rates for different seasons
- Predictive Scoring: ML-based maintenance quality prediction
This documentation is maintained alongside the Mainstay smart contract system. For the most current implementation details, refer to the source code in the lifecycle contract.
When an asset is used as collateral it is locked via lock_asset_as_collateral in the
Asset Registry and a LienRecord is written in the Lending contract
(record_lien). Before fix #995, defaulting a loan via slash marked the
loan as Defaulted but left the LienRecord intact. The asset remained
permanently locked, preventing the owner from transferring it or pledging it as
collateral for a new loan.
record_lien now writes a (LOAN_ASSET, loan_id) → asset_id mapping in
persistent storage. When slash is called:
- The loan is marked
Defaulted(unchanged behaviour). - The voucher stakes are slashed (unchanged behaviour).
- The
(LOAN_ASSET, loan_id)mapping is looked up. If anasset_idis found,release_lien_internalis called to remove the matchingLienRecordand delete the mapping key.
The release is best-effort: if no lien was ever recorded for the loan (e.g. the loan was not collateralised) the function completes normally without panicking.
After a slash the lien is released automatically inside the contract. Lenders
or integrators that also call lock_asset_as_collateral directly on the Asset
Registry should separately call unlock_asset_from_collateral to clear the
is_locked flag on the asset, as the lending contract does not call the Asset
Registry itself during slash.
get_maintenance_history returns the entire history vector in a single
read. At the default max_history of 200 that is a 200-element Vec,
which is expensive and can approach Soroban's per-call data and instruction
limits as history grows.
The function is deprecated for new integrations. Use one of the paginated alternatives instead:
| Function | Cap | Notes |
|---|---|---|
get_maintenance_history_paginated(asset_id, offset, limit) |
50 | New — preferred for external integrations |
get_maintenance_history_page(asset_id, offset, limit) |
100 | Existing — suitable for internal/admin tooling |
Both paginated functions follow the same contract:
offsetis zero-based. Anoffset≥ history length returns an empty vec (no panic).limit = 0returns an empty vec.limitvalues above the respective cap are silently clamped.- The returned slice may be shorter than
limitwhen the end of history is reached (partial last page).
// Before (deprecated — full unbounded read)
let history = lifecycle.get_maintenance_history(&asset_id);
// After (paginated — reads at most 50 records per call)
let page = lifecycle.get_maintenance_history_paginated(&asset_id, &0, &50);For UI components that display all records, iterate pages until an empty vec is returned:
let mut offset: u32 = 0;
let limit: u32 = 50;
loop {
let page = lifecycle.get_maintenance_history_paginated(&asset_id, &offset, &limit);
if page.is_empty() { break; }
// process page …
offset += page.len();
}