ERC-8467: Agent Credit Backers

A line of credit to an agent is a bet on whoever stands behind the agent. This draft lets a third party (a staker, a collateral vault, an insurer) stand behind a lender’s line to an agent identity and absorb its defaults, with the identity owner’s EIP-712 consent. The lender asks the backer before each draw (canBorrow), tells it of each draw (onBorrow), and on a default takes the principal from the backer and tells it (onDefault, with what was paid); lowered, removed or replaced lines are told once each (onRelease). Neither a backer nor the lent asset can block a repayment, a default or a removal: notifications run with a fixed gas limit, failures are logged, and calls to the asset are bounded too. It extends the Agent Credit Lines draft (ERC-8460: Agent Credit Lines).

Questions for lenders, backers and ERC-8210’s authors:

  1. On a default the lender takes the principal, then tells the backer. Notify-then-take would let a collateral vault convert collateral inside the hook, at the cost of a call that could fail before the loss is taken. Is take-then-notify the right default?
  2. Notifications get a gas limit the lender fixes, of at least 100,000. Is that floor right for backers that keep their own accounts?
  3. ERC-8210’s depositFor(agent, amount) funds an agent directly; here a backer stands behind a lender’s line. Should one standard cover both?

Take-then-notify seems right to me, and there’s one more reason for it: markDefault is open to anyone once defaultableAt passes, so under notify-then-take a vault would swap its collateral inside a transaction someone else times and builds around, which is an easy sandwich. A vault backer can read dueAt from loanInfo and convert during the grace window, in its own transaction.

Two things about what happens after a default:

  1. In the reference, unpaidLoss only goes up (markDefault adds to it and nothing lowers it), so a backer that comes up short by any amount gets no new credit at that lender again, for every identity it backs there. A way to pay the shortfall in later (anyone sends it, unpaidLoss goes down, with an event) gives a vault that converts slowly a way back, and gives the “outside this proposal” sentence somewhere to point.

  2. Once a loan is Defaulted, repay reverts and amountDue returns 0, so if the agent turns up with the money later there’s no on-chain path for it, and the backer that paid has no claim on it. For an insurer that recovery is a big part of the economics. I’d either keep a defaulted loan repayable, with the proceeds going to the backer up to what it paid, or say in the text that recovery happens off chain.

On the 100,000 floor: it’s comfortable for bookkeeping like StakeBacker’s, a few storage writes and one approve on a token the default has already touched. On ERC-8210, I’d keep the two apart: money from depositFor is the agent’s to spend, while a backing is a claim only the lender can use, and only on a default.

The missed-notification guidance says a backer should rebuild its records from backerOf, backedLine and loanInfo after a notification fails. Those getters don’t expose the amount collected. BackerCharged records it in an event, which a contract cannot read directly. Could the draft expose paid per defaulted loan?

For example, if a 10-unit default collects 4 units and onDefault reverts, the line still shrinks by 10. A backer can reconcile its remaining commitment, but an insurer tracking payouts per loan cannot determine that loan’s 4-unit payout from those getters. Would a backerPaid(loanId) view make sense? A test combining partial collection with a failed onDefault would clarify what can be reconstructed on chain.

@Zealynx @Revinand Your points are in the draft and the reference now (GitHub - priors-agents/erc-credit-backer-hooks: Draft ERC (Agent Credit Backers): third parties that stand behind a lender's line to an agent identity and take its loss, through lender hooks · GitHub, commit c2c8337).

Take-then-notify (Zealynx) stays, and the Rationale now gives your reason too: anyone may call markDefault once defaultableAt has passed, so a conversion inside the hook would run in a transaction someone else times and can sandwich. A vault backer reads dueAt from loanInfo and converts during the grace period, in its own transaction. The 100,000 floor stays. On ERC-8210, that is the line the draft draws as well: a backing is a claim only the lender can use, on a default.

paid per loan (Revinand). You’re right: the getters the missed-notification line named carry no payout, and a contract cannot read BackerCharged. lossInfo(loanId) now returns, for each defaulted loan drawn under a backer, what the backer paid, what is still unpaid, what was recovered and what went back to the backer, and Security Considerations name it. Your example is a test: 4 collected of a 10-unit default with onDefault reverting; the line goes to 40, BackerHookFailed is emitted, and lossInfo reads 4 paid and 6 unpaid.

A shortfall paid later (Zealynx). payLoss(loanId, amount): anyone pays in up to what the backer left unpaid, with a LossPaid event, and it counts as the backer’s own payment. The reference’s unpaidLoss goes down with it, and at zero the backer gets credit again. The “outside this proposal” sentence now points at it.

Recovery after a default (Zealynx) is on chain, but not through repay: in Agent Credit Lines a loan opens once and closes once, so repay and amountDue are unchanged and the loan stays Defaulted. recover(loanId, amount) takes up to the principal and the fee, applied in this order: the part the backer left unpaid, which the lender keeps, since the backer owed the whole principal; then the backer, up to what it paid, late payments through payLoss included; then the fee. Neither call calls the backer; its part reaches it as a plain transfer.

Nine new tests (79 in all), among them a fuzz of every unit after a default. With the recovery order reversed in the reference, the order test and the fuzz fail. The interface id of IBackedCreditLine changes; IAgentCreditLine does not.

A question back: the fee comes last because the backer never covered it. Would lenders rather have it before the backer is paid back?

Thanks for taking both in, payLoss and recover read well at c2c8337.

On the fee: I’d keep it last. The backer took a principal loss for the lender, and paying the lender’s income ahead of that just makes backing a line worth less to a backer.

The order does bite in one place though. recover pushes the backer’s part with a transfer, so on an asset with a freeze list (USDC has one), a frozen backer address makes every recovery that reaches that part revert. Security Considerations covers that, but with the fee behind the backer the lender can’t reach its fee either, once the backer has anything to get back. Crediting the backer’s part to a balance it withdraws, instead of transferring it inside recover, keeps fee-last and keeps recover from depending on the backer’s address at all.

Thanks for adding lossInfo and the test combining partial collection with a failed onDefault. That answers my earlier question. I have another question about revoking an unused owner consent.

In the reference implementation setBacker advances consentNonce, but removeBacker leaves it unchanged. Suppose an owner signs a line increase, then removes the backer before that signature is used. The backer can still submit it before its deadline and establish a new backing. Short deadlines limit that window, but an EOA owner cannot directly cancel the unused signature.

Should removal also advance the nonce? An owner-only invalidation function would cover consents signed before any backing exists too. A test where an unused line-increase signature is submitted after removal would make the intended behavior clear.

@Zealynx @Revinand Both are in the draft and the reference now (erc-credit-backer-hooks@259428f, and the PR, ethereum/ERCs#2111).

The fee stays last (Zealynx), for the reason you give: paying the lender’s income ahead of the backer’s principal makes backing a line worth less.

A backer the asset will not pay (Zealynx). You’re right, and it was worse than the Security Considerations line said: every recovery that reached the backer’s part reverted, and with the fee behind that part, so did the fee and the rest of the agent’s payments toward the loan. Pull costs one more call for the backer, a balance per backer, and a sum the lender has to keep out of what it lends. I think that is worth paying, because the push broke the draft’s own rule that a backer can stop money leaving, never money coming back. So recover now credits the backer’s part to dueToBacker(backer) and transfers nothing to the backer, and payBacker(backer), which anyone may call, pays it with a BackerPaid event, to the backer’s own address and no other, so the lender is never a way around a freeze. What is due to backers MUST NOT be lent, and availableCredit does not count it. In test_recover_frozenBacker_blocksNeitherTheRecoveryNorTheFee the backer is frozen after paying 10 at the default: a recovery of the whole amount due goes through and reaches the fee, and payBacker reverts while the address is frozen and pays the 10 once it is not. On c2c8337 that recovery reverts with the token’s freeze. test_dueToBacker_isNotLent covers the reserve.

Consents after a removal (Revinand). Yes to both. removeBacker now advances consentNonce, whoever calls it, so a raise signed before the removal cannot bring the backer back. revokeConsents(agentId), owner only, advances it at any time and emits ConsentsRevoked; that covers consents signed before any backing exists, which a removal cannot reach, and it leaves a backing in place alone. The test you asked for is test_removeBacker_voidsAConsentNotYetUsed: a raise to 80 is signed and not submitted, the owner removes the backer, and the backer’s setBacker with that signature reverts with BadConsent; the same after the backer removes itself. On c2c8337 the signature put the backer back. test_revokeConsents_byTheOwnerOnly_voidsConsentsNotYetUsed covers the new function and refuses the backer and a delegate. One effect to know: the nonce is per identity, so a backer that removes itself also voids a consent the owner signed for its replacement, and the owner signs again. I’d rather have that than a removal that leaves old consents live.

Four new tests, 83 in all; the five tests that expected the backer’s part to arrive inside recover now call payBacker. The interface id of IBackedCreditLine changes from 0x4efe38f1 to 0x9a4b754b; IAgentCreditLine does not.