Semantic Matching for Aave V3 Event Processing¶
Overview¶
Semantic matching is an architectural approach for associating blockchain events with logical operations based on their semantic relationships (user, asset, operation type) rather than numeric comparisons or positional proximity.
The Problem with Amount-Based Matching¶
Unit Mismatch¶
Aave V3 events contain amounts in different units:
Event Field |
Unit |
Example |
|---|---|---|
|
Underlying units |
8,414,088,469,488,124,792 |
Burn event |
Scaled units |
20,647,496 |
Burn event |
Scaled units |
8,727 |
Direct comparison of these values fails because:
Different assets have different decimals
Scaled amounts require index-based conversion
Pool revisions handle scaling differently
Event Ordering Issues¶
In batch transactions, events may be emitted in any order:
LogIndex 350: DEBT_BURN (wstETH)
LogIndex 357: LIQUIDATION_CALL (GHO)
The burn precedes the liquidation call in log index order, making positional matching unreliable.
False Negatives¶
Amount-based matching with tolerances can fail for:
Bad debt liquidations (burn amount > debtToCover)
Interest accrual (burn includes accrued interest)
Multi-asset liquidations (debtToCover is for primary asset only)
Semantic Matching Solution¶
Core Principle¶
If a debt burn exists for the same user and asset in the same transaction, it belongs to the liquidation.
This is based on the contract guarantee that:
Each user has at most one debt position per asset
Liquidations burn ALL debt positions for the user
No other operation burns debt in the same transaction
Implementation¶
def _collect_primary_debt_burns(...):
for ev in scaled_events:
# Filter by user
if ev.user_address != user:
continue
# Filter by asset (token contract address)
event_token_address = get_checksum_address(ev.event["address"])
if event_token_address != debt_v_token_address:
continue
# Match! No amount comparison needed
return ev
Why This Works¶
User uniqueness: A user can’t have two debt positions for the same asset
Transaction isolation: Events in the same transaction are causally related
Contract logic: The Pool contract burns ALL debts during liquidation
Comparison: Amount-Based vs Semantic¶
Scenario: Multi-Asset Liquidation¶
Transaction: User has GHO and wstETH debt, being liquidated
Events:
LogIndex 340: GHO_DEBT_BURN (primary)
LogIndex 350: DEBT_BURN wstETH (secondary)
LogIndex 357: LIQUIDATION_CALL debtAsset=GHO
Amount-Based Matching:
❌ GHO burn: 9,752,102,127,061,637 != 8,414,088,469,488,124,792 (debtToCover)
❌ wstETH burn: Skipped entirely (wrong asset)
Result: Both burns unmatched, validation fails
Semantic Matching:
✅ GHO burn: User matches + Asset matches = Matched
✅ wstETH burn: User matches + Different asset = Secondary burn
Result: All burns matched, validation passes
Validation Strategy¶
Semantic matching separates concerns:
Phase |
Responsibility |
Validation |
|---|---|---|
Matching |
Find related events |
User + asset correspondence |
Processing |
Validate business logic |
Amount sanity checks, balance updates |
Amount validation moves to processing:
def _process_debt_burn(...):
# Validate amounts make sense
if burn_amount > position.balance * 2:
logger.warning(f"Unusually large burn: {burn_amount}")
# Apply the burn
position.balance -= burn_amount
Edge Cases and Considerations¶
Multiple Burns Per Asset¶
Assumption: Only one burn per (user, asset) pair per transaction.
If violated: Second burn remains unmatched → validation error.
Rationale: Aave V3 contract burns the full debt balance in one operation. Multiple burns for the same asset would indicate unexpected contract behavior that should fail validation.
Zero-Amount Burns¶
Some burns may have amount=0 (e.g., pure interest accrual).
Handling: Semantic matching still applies. Zero-amount burns are matched and processed (no balance change).
Reorgs and Event Ordering¶
Semantic matching is reorg-safe because it doesn’t depend on log index proximity. As long as all events from the transaction are present, the matching succeeds regardless of order.
Architecture Implications¶
Benefits¶
Simpler code: No unit conversions or tolerance calculations
More robust: Works regardless of event ordering
Easier debugging: Clear semantic relationships vs complex math
Future-proof: Doesn’t depend on revision-specific scaling behavior
Trade-offs¶
Less validation at match time: Amount issues caught during processing
Trusts contract behavior: Assumes burns belong to liquidations if user+asset match
Harder to detect partial liquidations: Must check processing logic
When to Use Semantic Matching¶
Use semantic matching when:
Events have clear ownership (user + asset)
Transaction context establishes causality
Amount comparisons are unreliable (mixed units, revisions)
Events may be out of order (batch transactions)
Don’t use when:
Multiple operations of same type for same user/asset in one transaction
Amount matching provides critical validation
Event relationships are truly ambiguous
Implementation Guidelines¶
1. Match by Immutable Identifiers¶
Always use identifiers that won’t change:
✅ User address
✅ Token contract address
✅ Operation type
Avoid:
❌ Amounts (unit confusion, rounding)
❌ Log indices (ordering issues)
❌ Timestamps (within same block)
2. Separate Matching from Validation¶
# Matching: Find the event
matched_event = find_event(user=user, asset=asset)
# Validation: Check if it makes sense
if matched_event.amount > expected_maximum:
raise ValidationError("Amount too large")
3. Separate Enrichment from Matching¶
Amount-based validation in the enrichment layer is NOT the same as amount-based matching:
Enrichment/Validation (KEEP THIS):
Validates that calculated amounts are within expected tolerances
Handles revision-specific rounding differences
Ensures mathematical correctness
Example:
abs(calculated - expected) <= TOKEN_AMOUNT_MATCH_TOLERANCE
Amount-Based Matching (REMOVE THIS):
Uses amounts to correlate events with operations
Fragile due to unit mismatches and ordering issues
Example:
if burn_amount != debt_to_cover: skip_event()
Rule of Thumb:
Use semantic matching (user + asset) to find related events
Use amount validation (tolerances) to verify correctness after matching
Never skip events based on amount mismatches during matching phase
4. Fail Loud on Unmatched Events¶
Don’t silently ignore unmatched events:
if not matched:
raise ValidationError(f"Expected burn for {user}/{asset} not found")
5. Document Assumptions¶
Every semantic match should document the contract guarantee it relies on:
# Assumption: Pool contract burns ALL debts during liquidation
# Ref: Pool.rev_7.sol:executeLiquidationCall() lines 2490-2620
Anti-Patterns to Avoid¶
1. Log Index Proximity Matching¶
❌ DON’T: Assume events at adjacent log indices are related
# FRAGILE: Assumes burn is at logIndex + 1
if other_ev.logIndex == ev.logIndex + 1:
match_events()
✅ DO: Match by semantic identifiers regardless of position
# ROBUST: Matches by user and token, any position
if other_ev.user == ev.user and other_ev.token == ev.token:
match_events()
Why: Batch transactions emit events in unpredictable order. Log index proximity is coincidental, not causal.
2. Proximity Thresholds¶
❌ DON’T: Use arbitrary thresholds like “within 3 log indices”
# FRAGILE: Magic number threshold
if abs(log_index_1 - log_index_2) <= 3:
match_events()
✅ DO: Match by complete semantic identifiers
# ROBUST: Complete identifier matching
if ev1.token == ev2.token and ev1.from_addr == ev2.from_addr and ev1.to_addr == ev2.to_addr:
match_events()
Why: If token/from/to all match, the events are the same transfer regardless of position.
3. Amount-Based Event Correlation¶
❌ DON’T: Skip events because amounts don’t match
# FRAGILE: Amount-based skipping
if burn_amount < expected_amount:
continue # Skip this event!
✅ DO: Match by semantic criteria, validate amounts later
# ROBUST: Semantic matching
if burn.user == user and burn.token == token:
matched_burn = burn # Always match
# Validate during processing
if matched_burn.amount > position.balance:
logger.warning(f"Large burn amount: {matched_burn.amount}")
Why: Amounts can differ due to interest, fees, rounding. The semantic relationship (user + asset) is the ground truth.
Migration Guide¶
Converting Positional to Semantic Matching¶
Before (Positional):
# Look for burn at next log index
for other_ev in events:
if other_ev.logIndex == ev.logIndex + 1 and other_ev.type == BURN:
match = other_ev
break
After (Semantic):
# Look for burn by user and token, any position
for other_ev in events:
if other_ev.type == BURN and other_ev.user == ev.user and other_ev.token == ev.token:
match = other_ev
break
Testing:
Run against batch transactions (50+ operations)
Verify events are matched regardless of order
Check no events remain unmatched
Validate final balances match on-chain state
References¶
Issue 0029: Multi-Asset Liquidation Missing Secondary Debt Burns Fix
Issue 0028: Multi-Asset Debt Liquidation Missing Secondary Debt Burns
Contract:
Pool.rev_7.sol:executeLiquidationCall()File:
rust/crates/integrations/degenbot-aave/src/updater/transaction_processor.rs