ERC-8348: Financial Lease

Hi all,

We’ve been building tokenized financial leases and kept running into the same problem: there’s no standard way to expose a lease contract on-chain, so every integration is a custom adapter. We drafted an ERC for it and want to sanity-check the design here before opening the PR to ethereum/ERCs.

Quick scope note: by “financial lease” we mean the credit instrument (lessor funds an asset, lessee pays installments, usually with a purchase option at the end) — not NFT rentals. ERC-4907 handles temporary usage rights well, but it has no notion of installments, arrears, purchase options, or assignment. Compliance standards like ERC-3643 and ERC-7943 decide who can hold a token, not what a lease is. And the recent titled-asset family (ERC-8325..8330) covers the asset layer — binding, documents, compliance logs, NAV — but not the credit contract written on top of it. That contract layer is what we’re proposing to standardize.

The approach steals liberally from ERC-4626: don’t invent the instrument, standardize how to query and observe it. Semantics follow the UNIDROIT Convention on International Financial Leasing (1988) and IFRS 16 terminology, which keeps the interface jurisdiction-neutral without us having to argue about any particular country’s law.

The main design decisions, and why:

Lessor position as ERC-721, tokenId == leaseId. Assigning a lease or securitizing a portfolio becomes an NFT transfer, so existing marketplace/custody/vault tooling works with zero changes. Compliance hooks belong in the transfer path, not in this interface.

Schedules in units of account, not payment tokens. This one matters a lot in practice. In several of the largest leasing markets, contracts are inflation-indexed (UVA in Argentina, UF in Chile, IPCA-linked in Brazil), so a schedule of fixed token amounts is simply wrong there. In our design the schedule is immutable in abstract units, and convertToAssets / convertToUnits resolve to the ERC-20 payment asset at query time through a per-lease oracle. A fixed-rate lease is just the degenerate case: identity conversion, oracle == address(0). Rounding direction is normative (charge up, credit down — we all remember the 4626 inflation attacks), and every PaymentReceived event records the conversion rate applied, so historical payments can be audited without needing oracle history.

Two delinquency tiers. InArrears is objective: a due date passed unpaid, computable on-chain. InDefault is a formal act by an authorized declarer, because plenty of jurisdictions require notice or grace periods before default has legal effect. The standard records both and takes no position on local law.

There’s also an optional extension (IFinancialLeaseAssetBound) for when the leased asset itself is tokenized: the lease contract escrows the asset token, exercisePurchaseOption() settles atomically, and repossession on default is deliberately not automatic — it needs an authorized call plus a configurable timelock, so on-chain capability doesn’t outrun legal authority. If the asset implements ERC-4907, the lessee gets set as user for the duration. And if the asset is a titled asset, assetReference MAY be an ERC-8325 anchorId and agreementHash MAY commit to an ERC-8326 document bundle instead of a single document.

Abridged core interface:

interface IFinancialLease {
    function jurisdiction(uint256 leaseId) external view returns (bytes2);
    function agreementHash(uint256 leaseId) external view returns (bytes32);
    function denomination(uint256 leaseId) external view returns (string memory symbol, address oracle);
    function convertToAssets(uint256 leaseId, uint256 units) external view returns (uint256);
    function paymentAt(uint256 leaseId, uint256 i) external view returns (uint256 units, uint64 dueDate, bool paid);
    function outstandingUnits(uint256 leaseId) external view returns (uint256);
    function nextPayment(uint256 leaseId) external view returns (uint256 assets, uint64 dueDate);
    function arrears(uint256 leaseId) external view returns (uint256);
    function status(uint256 leaseId) external view returns (LeaseStatus);
    function pay(uint256 leaseId, uint256 assets) external;
    function exercisePurchaseOption(uint256 leaseId) external;
    // + events: LeaseCreated, PaymentReceived, DefaultDeclared, ...
}

Full draft and a reference implementation (Foundry tests, mock index oracle) are written; we’ll link the PR in this thread once it’s open.

Things we’re genuinely unsure about and would like input on:

  1. Payment imputation. We left the ordering of penalties/interest/principal unspecified on purpose — in some jurisdictions it’s mandatory law, so baking one order into the standard would break compliance somewhere. pay() emits enough data to reconstruct any imputation off-chain. Is that acceptable under-specification, or would you add an optional view exposing the implementation’s ordering?

  2. Oracle staleness. We require staleness to be observable (conversionRateAsOf) and documented, but don’t mandate a policy. We’re considering adopting ERC-8330’s split between publication staleness and valuation staleness for index oracles. Should the standard go further than observability?

  3. Lessee as a position. The lessor side is an NFT; the lessee is just an address with an assignment function. Tokenizing the lessee position too would enable lease-to-own secondary markets, but it doubles the compliance surface. Worth it in v1, or scope creep?

  4. Prior art. We reviewed ERC-4907, ERC-2615, ERC-3475, ERC-3525, ERC-4626, ERC-3643, ERC-7943 and the titled-asset family (ERC-8325..8330). Closest matches: ERC-2615 (stagnant Draft from 2020) adds rental/mortgage roles and liens to ERC-721 but has no credit semantics — no schedules, no indexed denominations, no delinquency model, no purchase option. ERC-3475 standardizes fungible debt securities (classes/nonces, redemption-focused), not bilateral contracts with named parties, formal default, or asset escrow. The titled-asset family standardizes the asset layer, not the contract layer — if anything we see composition there (lease default events as ERC-8328 entries, portfolio NAV via ERC-8330). The 2022 “Financial Primitive Standard” thread proposed generalized token accounting and never became an ERC. What are we missing?

  5. Composition with the titled-asset layer. Should subject identifiers (leaseId vs ERC-8325 anchorId) be alignable by convention? And is a lease-portfolio NAV profile for ERC-8330 worth specifying?

Happy to be told any of this is wrong … that’s what the thread is for.

3 Likes

PR is now open: Add ERC: Financial Lease by javierpmateos · Pull Request #1907 · ethereum/ERCs · GitHub (ERC-8348)

The reference implementation is included under assets/ … Foundry suite with 8 passing tests, including regression tests for two bugs we caught in internal review (schedule state misreporting overdue installments as paid, and penalty payments incorrectly reducing principal) and a fuzz
test over the directional rounding invariant. Feedback on either the spec or the implementation is welcome, here or on the PR.

Following a discussion in the titled-asset architecture thread, here is a concrete mapping between ERC-8348 fields/events and that family. Composition is entirely optional … ERC-8348 has no dependency on these interfaces … but where a lease is written over a titled asset, the correspondence is direct:

ERC-8348 Maps to Notes
assetReference(leaseId) ERC-8325 anchorId Needs registry address + chain context to disambiguate across registries (see below)
agreementHash(leaseId) ERC-8326 bundle hash bytes32 is already bundle-hash compatible: lease contract, schedule annex, guarantees, insurance as one canonical bundle
LeaseCreated ERC-8328 issuance entry
LesseeAssigned ERC-8328 transfer-check entry Lessee assignment is the compliance-relevant one; lessor assignment is an ERC-721 transfer
DefaultDeclared / DefaultCured ERC-8328 enforcement + correction entries Correction semantics map cleanly onto cure
AssetReleased (repossession) ERC-8328 enforcement entry
PurchaseOptionExercised ERC-8328 redemption entry
Lessor position (ERC-721) transfers ERC-8327 route check Corridor rules on portfolio assignment across jurisdictions
Portfolio of lease positions ERC-8330 NAV subject See staleness note below

Two points worth flagging:

Subject identifiers. Agreed that alignable-by-convention beats mandatory —
an asset and an agreement written over it are different objects with different
lifecycles. Concretely, we’re considering an optional extension rather than
changing the core:

interface IFinancialLeaseAnchored {
    /// @return chainId    CAIP-2 style chain reference of the registry
    /// @return registry   ERC-8325 registry address
    /// @return anchorId   anchor identifier within that registry
    function assetAnchor(uint256 leaseId) external view returns (
        uint256 chainId, address registry, bytes32 anchorId);
}

That keeps leaseId as the lease layer’s own identifier, keeps ERC-8325 focused on asset anchoring, and makes the relationship explicit and unambiguous where it exists.

NAV staleness for lease portfolios. This is where the publication/valuation split earns its keep. A lease portfolio NAV has two independent time anchors: when the provider published, and the asOf timestamp of the index rate used to convert the outstanding schedule (ERC-8348 exposes this via conversionRateAsOf). For an inflation-indexed portfolio these can diverge materially … a NAV published today off a stale index reading understates the receivable. Valuation staleness in ERC-8330 maps directly onto the index oracle’s asOf, which suggests a lease-portfolio NAV profile could specify exactly that binding.

Spec and reference implementation updated with the two extensions this discussion surfaced. Both are optional; the core interface is unchanged.

Anchored leases (IFinancialLeaseAnchored) — the (chainId, registry, anchorId) tuple we converged on, exposing the ERC-8325 binding explicitly rather than overloading assetReference. A lease may be unanchored (zero values); leaseId stays the lease layer’s own identifier.

Input freshness — this is where I ended up going further than the original question. Rather than a single observability flag, the lease layer now exposes per-input freshness through one parameterized view:

enum ServicingInput { IndexObservation, Collections, ArrearsRecord, InsuranceStatus, AssetCondition }
function inputFreshness(uint256 leaseId, ServicingInput input)
    external view returns (uint64 asOf, uint64 maxStaleness);

The reasoning: a lease sits beneath a NAV layer that needs to know how old its inputs are, but those inputs don’t share a single freshness. IndexObservation is derived on-chain (it’s the only one whose staleness can revert pay(), since conversion needs it); the rest are servicer attestations that are reported, never enforced … updating them cannot touch balance, status, or arrears. maxStaleness is an issuer-declared suggestion, not a constraint the standard imposes.

That gives the clean layer boundary: a NAV layer expresses publication and valuation freshness for the snapshot; the lease layer expresses freshness for each input beneath it; and an upper layer’s freshness is bounded by the oldest input below it. The reference implementation isolates all of this from the payment path — there’s an explicit test asserting servicing updates don’t move lease state.

Reference impl: 14 passing tests (Foundry), including the isolation test and anchored-extension coverage. Same PR: Add ERC: Financial Lease by javierpmateos · Pull Request #1907 · ethereum/ERCs · GitHub

@Musyimi97 @Krumg1 … this should be the substantive version to review against.

1 Like

Hello @javierpmateos ,

Thank you for this ERC!

Regarding ERC-165 support, it could be relevant to indicate which value needs to be returned when only the main interface is implemented and when the extension is implemented too since in this case there is another supplementary functions.

  • Implementations of this extension MUST signal support via ERC-165.

Best!

@AccessDenied403 Added … the spec now publishes the identifiers explicitly:

Interface Identifier
IFinancialLease 0x11528c7a
IFinancialLeaseAssetBound 0xf71550a8
IFinancialLeaseAnchored 0x09fce36a

Each is the XOR of the selectors declared in that interface alone, and the extension bullet now names the concrete value instead of a generic ERC-165 reference. The reference implementation asserts all three in tests, so a signature change can’t silently break detection.

Your comment also surfaced something bigger: IFinancialLease only existed inside the implementation contract, not as a standalone interface file … which is what a third-party implementer actually needs. That’s fixed too. Thanks for catching it.

Separately, the reference implementation now includes oracle adapters: a Chainlink AggregatorV3 wrapper, a composed two-feed adapter (for cases like a UVA-denominated lease settled in USDC), and an attested adapter for indices with no on-chain feed yet … UVA and UF among them. The attested one documents its trust model explicitly rather than hiding it.

Building them surfaced two spec gaps, both now fixed. First, conversionRateAsOf needed an explicit scaling rule: base units of the payment asset per 1e18 units of account, absorbing both the feed’s and the token’s decimals. Without it, two implementations could scale differently and silently disagree. Second, for composed feeds asOf must be the minimum of the inputs — a composition is only as fresh as its stalest leg.

25 passing tests. Same PR.

1 Like

Ran a full end-to-end scenario suite against the reference implementation … 36-installment UVA lease with monthly inflation, delinquency with penalty accrual and cure, portfolio assignment mid-life, index deflation, 60-installment rounding stress, and a full cycle through the Chainlink adapter. Global invariants asserted after every operation.

Two things worth reporting.

The rounding held. Sixty installments with deliberately non-divisible amounts and a non-round monthly rate still land on outstandingUnits == 0 exactly, and the residual favours the lessee by 25 base units of a 6-decimal token over the whole schedule. Directional rounding plus carry-forward of the ceil excess to the next installment is what makes it converge.

The scenarios also surfaced a real defect the unit tests had missed … in fact, one unit test had asserted the bug as correct behaviour. Because accrual was lazy, view functions could report Active and arrears() == 0 on a lease that was economically months delinquent, until some transaction happened to touch it.
For a standard whose purpose is letting third parties read lease state, that breaks the premise: a credit scorer or NAV consumer would see a healthy portfolio. Fixed by projecting accrued state in the views without writing storage, with _accrue refactored to consume the same projection so read and write paths can’t diverge.

The spec now requires it explicitly: views MUST report state accrued as of the querying block.timestamp, regardless of when the implementation last persisted.
Also added the due-date boundary convention (payment exactly at dueDate is not overdue) and clarified that ArrearsAccrued signals the objective fact of an elapsed due date, not necessarily economic loss.

38 passing tests. Same PR.

@javierpmateos reviewed the updated spec and the PR. This is a strong revision. Four points.

  1. Partial-zero tuples in IFinancialLeaseAnchored. The unanchored case (all-zero tuple) is clean, but the spec should state that a partially zeroed tuple MUST be invalid: either all three of (chainId, registry, anchorId) are zero, or all three are set. Without that rule, a lease returning a nonzero anchorId with a zero registry is ambiguous, and every consumer defines its own handling.

    Small terminology point: a uint256 chainId is an EIP-155 chain ID; CAIP-2 is normally a string identifier such as eip155:1. Either representation is workable, but the comment should describe the value returned.

  2. asOf semantics in inputFreshness. The text describes asOf as the timestamp of the datum, but the reference implementation currently sets it to block.timestamp when the servicer submits the update. Those can diverge materially: a collections figure observed at month-end but reported two weeks later is fresh by report time and stale by observation time.

    If asOf is intended to mean report time, I would name it accordingly. If it is intended to mean observation time, the servicer should supply it, with report time separately available.

  3. Who sets maxStaleness? The text calls it issuer-declared, but the reference implementation allows the authorized servicer to set it in updateServicing. That is a meaningful governance choice, since a servicer can widen the tolerance applied to its own attestations. Either make it issuer/configurer-controlled policy, or describe it as servicer-declared and make that trust assumption explicit.

  4. Closed enum versus extensible identifiers. ServicingInput covers leasing today, but lease structures with inputs outside the five, such as residual-value appraisals, guarantor status, or collateral substitution, would need an interface change. Have you considered a domain-separated bytes32 input identifier with the five base inputs as named constants? That extends without touching the interface, as with indicators, event types, and party roles in our family.

On freshness composition, the separation now looks clean: ERC-8348 can expose the freshness of lease-specific inputs, while ERC-8330 expresses publication and valuation freshness for a resulting NAV snapshot. I would avoid making an automatic rule that equates a NAV valuationTimestamp with the oldest lease input, since that relationship depends on the valuation methodology and the materiality of each input.

The enforcement-isolation test is the right safeguard. It makes the claim that servicing attestations are advisory, rather than lease-state-changing controls, directly testable.

@Musyimi97 This is the kind of review the spec needed … thanks for going through it at this level.

All four applied, with a refinement on two.

1. Partial-zero tuples. Added: the tuple MUST be all-zero or fully populated. And you were right on the terminology … uint256 is an EIP-155 chain ID; CAIP-2 would be the string form. Comment fixed.

2. asOf semantics. Your sharpest catch, and I ended up rejecting both options in favour of exposing both timestamps:

function inputFreshness(uint256 leaseId, bytes32 input)
    external view returns (uint64 observedAt, uint64 reportedAt, uint64 maxStaleness);

The servicer supplies observedAt; the contract records reportedAt at submission. A valuation consumer needs observation time to judge how economically old an input is; accountability needs report time to know when the chain learned of it. Naming only one loses information the other layer may need … and structurally it parallels your publication/valuation split, which seems right given these compose. Without observation time, a servicer can sit on a month-old figure and have it read as fresh, which is the failure mode ERC-8330 exists to prevent one layer up.

3. maxStaleness. Agreed … a servicer declaring the tolerance applied to its own attestations is self-assessment. Moved to lease configuration, set at creation, outside the attestation path.

4. Closed enum. You convinced me, and residual-value appraisal is the argument: it’s a central leasing input (it determines the purchase option price and is a standing audit item)and our five didn’t include it. If the taxonomy is already incomplete, a closed enum in a Final standard freezes that incompleteness permanently. Now domain-separated bytes32, with one addition to your proposal: the base identifiers are declared in the interface rather than left to implementations, so the common set doesn’t fragment across deployments. Extensions get domain separation; the common cases stay common.

On freshness composition. Fair correction … softened. Bounding an upper layer’s freshness by the oldest input below it is a reasonable heuristic, not a normative rule: whether a stale input degrades a NAV depends on the valuation methodology and that input’s materiality. The spec now describes what each layer exposes and leaves composition to the consumer.

Interface IDs republished, since inputFreshness changed signature:

Interface Identifier
IFinancialLease 0xe3e0ac48
IFinancialLeaseAssetBound 0xf71550a8
IFinancialLeaseAnchored 0x09fce36a

45 passing tests, including regression coverage for each of the four changes.
Same PR.

1 Like

Ran a security audit against our own reference implementation and it turned up two exploitable issues. Both are fixed; posting them here with the spec changes they produced, since in both cases the root cause was the specification permitting the wrong implementation rather than the code being wrong on its own terms.

Penalty accrual depended on transaction history. The implementation accrued penalties by re-applying a periodic rate to a running balance that already included accrued penalty. With 1,000 units in arrears at 10 bps/day over 90 days: one accrual at the end yields 90.00 units; ninety daily accruals yield 94.13 … same schedule, same payments, same elapsed time,
+4.58%. And pay() is permissionless, so the lessor can call pay(leaseId, 1) daily on their own lease … funds go from them to themselves, cost is gas — and push accrual toward the higher figure. The party who benefits from a larger number is also the one who can produce it.

The spec now requires the reported figure to be a function of the schedule, the payment history and elapsed time, and to not depend on the number or timing of the calls that persisted state. It deliberately doesn’t prescribe simple versus compound … that’s the implementation’s choice, provided it’s evaluated in closed form.

A second symptom of the same root cause: the old code granted a silent grace period, since the first call that noticed an overdue installment computed zero penalty on it regardless of how late it already was. Now measured from each installment’s own due date.

Lessee reassignment could be used to take the purchase option.
assignLessee accepted the defaultDeclarer as caller, and defaultDeclarer defaults to the lease originator — the lessor. So: lease reaches Completed, lessor reassigns the lessee role to themselves, calls exercisePurchaseOption, pays the price to ownerOf(leaseId) which is
themselves. Net cost zero. The lessee who paid the full schedule loses the option at the last step, and the attack gets cheaper the closer they are to finishing.

The old normative text said only that assignment “MUST emit LesseeAssigned and SHOULD be gated by the implementation’s compliance layer” — which reads as KYC, not as economic access control. Now: authorization must come from the outgoing lessee or from a separately granted authority, and holding a role for default declaration, servicing, or the lessor position does not confer it. Roles tied to the lessor position should follow the NFT rather than freeze at origination.

Terminated is now reachable. It was declared in LeaseStatus but no code path assigned it … an empty promise in a public interface, and a lease in InDefault had no terminal exit. Two causes in core: mutual agreement (propose/accept, from any non-terminal status) and default (lessor only, InDefault required, after a configurable delay that must be non-zero). Reasons are domain-separated bytes32, so other causes extend without touching the interface.

Post-termination settlement is explicitly out of scope: what is owed after early termination depends on the agreement, the jurisdiction and the product. The spec requires reported economic quantities to stop changing at termination without prescribing how, and defines no state-changing payment operation once a lease is terminal.

The spec also now states things it previously left to inference: a state transition table, an operation-by-status matrix, terminality as explicit status membership rather than enum ordering, and that default is curable … which makes pay() in InDefault a consequence rather than a separate rule. Working through that matrix cell by cell surfaced decisions nobody had made: whether lessee reassignment is permitted while in default, for instance, turned out to be commercial policy rather than interoperability, and is now explicitly left to implementations with a note that they should document their choice.

74 passing tests. Both findings have regression tests that fail against the old behaviour.
Same PR.

@Musyimi97 @Krumg1 — flagging since this changes parts of what you were
going to review.

1 Like

A few ideas and suggestions:

  1. Keep the lessor 721 vanilla. Compliance belongs in wrappers or implementation transfer paths, not the base interface, that’s what preserves the 4626-style “existing tooling just works” property. And make it explicit that leaseId is only meaningful scoped to the emitting contract: cross-references (including titled-asset anchorIds) should be (contract, id) tuples, never aligned id spaces.
  2. Consider ERC-6551 for the asset-bound extension. Escrow the asset token in a token-bound account owned by the lease NFT: escrow then travels with assignment for free, portfolio sales are pure 721 transfers, and exercisePurchaseOption() is a transfer out of the TBA. One caveat, it must be a restricted 6551 account with the lease contract as sole executor, or the current owner could bypass your repossession timelock.
  3. Lessee side: agree, not in v1. If tokenized later, ERC-5192 locked semantics with consent-gated unlock fits the obligation shape; or expose the lessee through ERC-4907’s userOf as a read-only view so existing indexers render the relationship for free.
  4. Sharpen agreementHash to commit to the signed instrument. As written, one implementer will hash the unsigned template and another the executed copy, and verification silently breaks. Require the hash to commit to the exact bytes of the fully executed lease (signatures included, or the 8326-bundle equivalent), emit it in LeaseCreated, and add AgreementAmended(leaseId, oldHash, newHash), real leases get restructured.

Point 4 is the one I want to fix immediately … it’s a genuine spec bug and the same class we’ve been hunting all along: semantics two implementers infer differently, with no way to notice. The spec says agreementHash binds on-chain state to an off-chain agreement and never says which bytes. One implementer hashes the unsigned template, another the executed copy, both are conformant, and verification silently fails.

Making it commit to the fully executed instrument ( signatures included, or the equivalent document-bundle commitment ) and emitting it in LeaseCreated.

AgreementAmended is a good catch too; real leases do get restructured and today the hash is effectively immutable with no way to express a novation. One thing I’d add: a mutable hash means whoever can change it can rewrite which agreement the contract is bound to. That needs the same authorization treatment we gave lessee reassignment … not a role that happens to be lying around. Probably: both parties, or an explicitly granted amendment authority, never defaulted to the originator.

On (1): the NFT is already vanilla and compliance already lives in the transfer path rather than the interface. Your second half isn’t stated though.
The anchored extension does use a (chainId, registry, anchorId) tuple for exactly the reason you give, but the general principle … that leaseId is only meaningful scoped to its emitting contract, and cross-references must be tuples rather than aligned id spaces … isn’t written down anywhere. Adding it as a normative requirement.

On (2): the ERC-6551 pattern is elegant and the caveat is the important part. Escrow travelling with assignment for free is a real gain, but a non-restricted account means the current holder can move the asset out and walk past the repossession timelock … which is precisely the property that extension exists to protect. I’d rather document it as an implementation pattern in the asset-bound extension, with the sole-executor requirement stated as a condition, than make 6551 a dependency. That extension isn’t implemented yet, and binding it to another standard before there’s a reference implementation seems premature.

On (3): agreed on v1. The userOf read-only view is a nice detail … free indexer rendering without tokenizing anything. Noting it; it would change the core interface ID, so it belongs in the next batch rather than now.

Point 4 goes in the next batch of spec changes; will flag here when it’s in.