Abstract
This proposal is a profile of the ERC-8004 Reputation Registry for loan outcomes: loans opened,
repaid on time, repaid late, defaulted. It adds no core contract. A lender posts ordinary giveFeedback entries with
two tags, credit.statement and credit.defaulted, whose files travel inside the entry as data: URIs.
A statement commits, with a Merkle root, to every loan of an agent that closed in a block window at the lender's contracts. Each loan is a leaf naming the lender's own log that closed it. A reader holding nothing but an RPC endpoint can rebuild every leaf from those logs, check the root and the totals, and sample the money that moved.
A lender's statements for one agent are chained by hash over back-to-back windows. Skipping a window or revoking a statement is visible to every reader. Dropping a loan at a contract the lender declares is visible to every reader that rebuilds the statements, and so is a default after the last statement, which a lender that stops posting would otherwise leave out.
An optional part, a profile of ERC-8092, lets a lender publish which contracts its writer speaks for, as associations signed by both the lending contract (or its ERC-173 owner) and the writer. A reader then takes that list from the chain instead of from the writer's files.
Motivation
ERC-8004 gives agents an identity and a place where anyone can leave feedback. Most feedback is an opinion, and an opinion costs nothing to fake. A repaid loan is different: someone put money at risk and got it back, and both transfers are on chain.
Credit history is the one kind of reputation that can be checked against money that moved. Today every lender to agents keeps it in its own format, so a record earned with one lender is invisible to the next. Lenders also use values that cannot be compared: scores on different scales, counts, amounts.
Goals:
- No new core contract. It works on every chain where the ERC-8004 registries are deployed, today.
- Checkable from the chain alone. Every number in a statement can be rebuilt from the lender's logs. A reader needs no API from the lender.
- Omission is visible. A lender cannot quietly leave out a default at the contracts it declares: windows are contiguous and chained, they cover each contract from its first block, the root covers every loan closed in the window, and a reader that rebuilds scans past the last window too, so stopping does not hide a default either. Which contracts a lender has is the reader's call (see Readers).
- Scales to many small loans. Agents borrow often and in small amounts. In the reference deployment, 140 agents closed 28,008 loans in 16 days. One entry per loan would flood the registry. One statement per agent per period, at about 2 to 4 KB, does not.
- Provable on chain, one loan at a time. A contract can verify that a given loan is in a statement with a Merkle
proof (OpenZeppelin
MerkleProof).
Specification
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.
Terms
- Lender: whoever extends credit to an agent.
- Source: a lender contract whose logs record loans opening and closing.
- Writer: the address that posts the lender's credit entries (the entry's
clientAddress). - Loan: one extension of credit with an id unique within its source, a principal and a due time.
- Leaf: one closed loan.
- Statement: the leaves of one agent at one writer's sources over a block window.
- Class: what secured the loan.
unsecured, orsecured:<kind>where kind is one ofstock,pt,lst,stable,nftorother. - Manifest: a reader's own list of a writer's sources, taken from somewhere other than the writer's files (for example from on-chain associations, see On-chain manifests).
Entries
A writer posts entries with giveFeedback(agentId, value, valueDecimals, tag1, tag2, endpoint, feedbackURI, feedbackHash):
| tag1 | value | tag2 | when |
|---|---|---|---|
credit.statement | principal repaid on time in the window | the leaves' class; mixed if more than one; the agent's current line class if no leaf | periodically, per agent |
credit.defaulted | principal of the defaulted loan | the loan's class | when a loan defaults |
valueDecimalsMUST equal the decimals of the statement'sasset, and amounts are in the asset's base units.endpointSHOULD be empty.feedbackURIMUST be exactlydata:application/json;base64,followed by the base64 of the file's bytes (RFC 4648 standard alphabet, padded, no other character), so the entry carries its own evidence and outlives any server.feedbackHashMUST bekeccak256of those bytes.- The file's bytes MUST be its canonical encoding (see Encoding).
- A writer MUST NOT revoke a credit entry.
- A writer SHOULD be an address used only for credit entries, and MUST NOT own an agent it writes about (the registry already refuses self-feedback).
Sources
Every file lists the sources its leaves come from:
{
"chainId": 4663,
"contract": "0x…",
"blocks": [71702460, null],
"events": {
"open": "Borrowed(uint256 indexed loanId, uint256 indexed agentId, uint256 indexed sponsorId, uint256 principal, uint256 fee, uint64 dueAt, address to)",
"repaid": "Repaid(uint256 indexed loanId, uint256 indexed agentId, uint256 principal, uint256 fee, address payer)",
"defaulted": "Defaulted(uint256 indexed loanId, uint256 indexed agentId, uint256 indexed sponsorId, uint256 principal, uint256 sharesBurnt, address owner)"
},
"fields": { "amount": "principal", "dueAt": "dueAt" }
}contractis ERC-55 checksummed. A source is identified by (chainId,contract).eventsare the exact event signatures the contract emits, with parameter names. The three are distinct and not anonymous.- Each of the three events MUST have
uintparameters namedloanIdandagentId, plus theuintparameter named byfields.amount. Theopenevent MUST also have theuintparameter named byfields.dueAt(unix seconds). agentIdSHOULD be indexed, so a reader can filter by agent.blocksis[first, last]: a block at or before the contract's first loan (its deployment block is a safe choice), and its last block of lending, ornullwhile it still lends.- Along a (writer, agent) chain, a source's
first,eventsandfieldsMUST NOT change.lastmay change once, fromnullto a block. - A reader MUST NOT narrow its log scan with a writer's
blocks: a writer could end a range early to hide later loans. It MAY narrow it with the ranges of a manifest it trusts.
A lender whose contract does not emit such events cannot be checked by readers and SHOULD NOT write this profile.
Encoding
A file is UTF-8 JSON in the canonical form of RFC 8785 (JCS): object keys sorted by UTF-16 code units, no whitespace, strings escaped as JCS does. One file has one byte string, so every reader gives it the same meaning.
- Every JSON number in a file is an integer from 0 to 2^53 - 1. Ids (
agentId,loan) and amounts (valueand everyamount) are decimal strings without leading zeros, because they areuint256(valueisint128) and can pass 2^53. ERC-8004's example file writesagentIdandvalueas JSON numbers; this profile writes them as strings. - Addresses are ERC-55 checksummed.
agentRegistryandclientAddressare CAIP-10 (eip155:<chainId>:<address>) on the same chain. Hashes are lowercase0x-prefixed hex.createdAtis UTC to the second (2026-10-07T14:29:10Z). - A file has exactly the fields this document lists. Only
lender, andlender.aboutwithin it, are optional. - A file MUST NOT exceed 8,192 bytes. It holds at most 16 sources and 16 anchors.
lender.nameis 1 to 64 characters (UTF-16 code units),lender.about1 to 256, an event signature at most 512, and no string has control characters.
A reader MUST reject a file whose URI is not exactly the prefix and canonical base64, whose bytes are not UTF-8, or whose bytes are not the canonical encoding of the JSON they parse to. The last check rejects duplicate keys (parsers disagree on which one wins), whitespace, and other spellings of a number.
Leaves
A leaf is one loan of the agent closed at a source by a repaid or defaulted log.
| field | meaning |
|---|---|
loan | loanId, a decimal string |
amount | the close log's amount field, in base units, a decimal string |
dueAt | the open log's due time |
closedAt | the timestamp of the block holding the close log |
outcome | 1 repaid on time (closedAt <= dueAt), 2 repaid late, 3 defaulted, 4 recovered (reserved: a v1 file MUST NOT carry it, see Open questions) |
tx, log | the close log's transaction hash and log index in its block |
block | the close log's block (a lookup aid, not committed) |
src | index of the leaf's source in the file's sources (not committed) |
cls | the loan's class (the lender's claim, not committed) |
openBlock, openTx, openLog | where the open log is (not committed; REQUIRED in written leaves, for a light reader) |
The leaf hash, as OpenZeppelin's MerkleProof expects:
leafHash = keccak256(bytes.concat(keccak256(abi.encode(
uint256 chainId, address contract, uint256 agentId, uint256 loanId, uint256 amount,
uint64 dueAt, uint64 closedAt, uint8 outcome, bytes32 txHash, uint32 logIndex))))Leaves are ordered by close block, then log index. The root is a binary Merkle tree over the leaf hashes in that order:
each pair is hashed sorted (keccak256(min ‖ max)), and an odd last node is carried up unchanged. The root of no leaves
is bytes32(0).
Statement file
The file holds ERC-8004's off-chain feedback fields and one object, credit. It is shown here in reading order; its
bytes are canonical (keys sorted, no whitespace):
{
"agentRegistry": "eip155:4663:0x8004A169FB4a3325136EB29fA0ceB6D2e539a432",
"agentId": "6197",
"clientAddress": "eip155:4663:0x…writer",
"createdAt": "2026-10-07T14:29:10Z",
"value": "…", "valueDecimals": 6,
"tag1": "credit.statement", "tag2": "unsecured",
"credit": {
"v": "erc8004-credit/v1",
"lender": { "name": "…", "about": "https://…" },
"asset": "eip155:4663/erc20:0x…",
"decimals": 6,
"sources": [ { "…": "as above" } ],
"seq": 2,
"prev": "0x…feedbackHash of seq 1",
"window": { "fromBlock": 75927565, "toBlock": 82543414, "toTime": 1791383350 },
"opened": { "count": 115, "amount": "…" },
"closed": {
"onTime": { "count": 114, "amount": "…" },
"late": { "count": 0, "amount": "0" },
"defaulted": { "count": 0, "amount": "0" },
"recovered": { "count": 0, "amount": "0" }
},
"classes": { "unsecured": { "onTime": { "…": "…" } } },
"outstanding": { "count": 1, "amount": "…" },
"leaves": 114,
"root": "0x…",
"anchors": [ { "src": 0, "cls": "unsecured", "loan": "…", "amount": "…", "dueAt": 0, "closedAt": 0, "outcome": 1, "block": 0, "tx": "0x…", "log": 0, "openBlock": 0, "openTx": "0x…", "openLog": 0 } ]
}
}valueiscredit.closed.onTime.amount, as a decimal string of base units. JSON numbers lose precision past 2^53.assetis the token lent, as a CAIP-19 id:eip155:<chainId>/erc20:<address>. In v1 it is an ERC-20 token.decimalsis the token'sdecimals().assetanddecimalsMUST NOT change along a (writer, agent) chain. A lender that lends several assets to one agent uses one writer address per asset.seqstarts at 1 for each (writer, agent) pair and increases by 1.previs the previous statement'sfeedbackHash, ornullfor seq 1.window.fromBlockis the previous statement'stoBlock + 1.toTimeis the timestamp oftoBlock.toBlockSHOULD be final on its chain.- Seq 1's window MUST start at or before the first block of every source it declares, so it covers the agent's whole history with the lender at those sources. Seq 1 MUST declare every source the lender has used for this agent.
- A later statement MUST declare every source that has a leaf or an opened loan in its window, and every source an
earlier statement declared whose
blocksreach its window (first <= toBlock, andlastisnullor>= fromBlock). A source it declares for the first time MUST have its first block at or after itsfromBlock. - A statement MUST include every leaf whose close log is at one of its sources within the window.
leavesis the number of leaves.rootisbytes32(0)exactly whenleavesis 0.closed.recoveredMUST be{ "count": 0, "amount": "0" }in v1 (recovery is reserved), and the outcome counts add up toleaves.openedcovers the loans whose open log is in the window.outstandingcovers the loans opened at or beforetoBlockand not closed at or before it.classessplitsclosedby class, as claimed by the lender: one key per class with a loan in the window, adding up toclosed.tag2is its one key,mixedfor more than one, or the agent's line class when it is empty.anchorsare the latest leaves in leaf order, all in the window, written in full (RECOMMENDED: 5). Statement files SHOULD stay under 4 KB, and a writer drops the oldest anchors first to fit.
Default file
A credit.defaulted entry carries the same head, then credit with v, lender, asset, decimals, sources (one
source) and leaf (the defaulted leaf, written in full). Its asset and decimals are its chain's, and tag2 is the
leaf's class. The writer SHOULD post it within 24 hours of the default. The statement whose window holds the default
MUST count it.
Writers
A writer:
- MUST post statements so that each agent's windows are back to back from its first statement onward;
- MUST start each agent's seq 1 at or before the first block of every source it declares;
- SHOULD post a statement at least every 30 days while the agent has loans opened or closed;
- MUST NOT revoke credit entries;
- MUST NOT post two statements with the same
seqfor one agent; - SHOULD check each file against this profile before posting it: a posted file cannot be replaced.
Readers
A reader:
- MUST check each entry's bytes: the URI (see Entries), that
feedbackHashequalskeccak256of the bytes it carries, the encoding (see Encoding), every field and limit of this profile, and that the file's spec fields match the entry; - MAY take a writer's entries from an index instead of scanning the registry's
NewFeedbacklogs. It MUST then read each from its transaction receipt (the registry's log, for this agent, from this writer), and check that the entries'feedbackIndexvalues, credit or not, run from 1 togetLastIndex(agentId, writer)with none missing: an index that leaves out an entry would hide it; - MUST walk each (writer, agent) chain and treat any of the following as a failure of that writer for that agent: a
revoked credit entry, a seq gap, a
prevmismatch, windows that are not back to back, a seq 1 that starts after the first block of a source it declares, a statement that leaves out a source it must declare, a source whose declaration changes, or an asset or decimals that change; - MUST check that each statement's window ends before the block its entry was posted in, and that
toTimeis that block's time (a statement cannot pre-claim blocks still to come); - MUST report where each chain ends (its last window's
toBlockandtoTime) against the block it reads at. Loans after the last window are in no statement, and a reader MUST NOT present a chain's figures as the agent's history past that block; - MUST, when it rebuilds, also scan each source from the end of the last window to the block it reads at, and treat a
default there that no
credit.defaultedentry of the writer reports, 24 hours or more before the block it reads at, as a failure of that writer for that agent. A younger one is reported as awaiting its entry (the writer has 24 hours to post it). A writer that stops posting would otherwise leave that default out. The loans closed and opened there are in no statement: a reader reports them as such and does not count them; - MUST check each anchor and each default leaf against the source's logs. That means the close log at (
tx,log) is the source's event for this loan, agent and amount; the open log at (openTx,openLog) inopenBlockgivesdueAt; the close block's time isclosedAt; and the outcome fits the dates; - SHOULD check that the asset moved for those loans: at least the principal out of the source when the loan opened
(default leaves too), and at least the principal into it on repayment. A reader that checks funds MUST treat an asset
that is not an ERC-20 on the sources' chain, or whose
decimals()is notdecimals, as a failure, not as a reason to skip the check. Those loans are the only tie between the files'assetanddecimalsand the sources (a manifest pins sources, not the asset), so such a reader MUST also fail a chain whose statements close loans but carry no anchor, when the writer has no default entry for the agent; - MUST NOT present the totals of a statement it has not rebuilt as checked, nor add them to totals it reports as checked. Without a rebuild they are the writer's claims;
- MUST NOT add a writer's rebuilt totals to totals it reports as checked unless it holds a manifest naming that writer (see below), and MUST say, when it reports them without one, that they were rebuilt from the sources the writer declares. A rebuild shows that the statements match the logs of the contracts it read. Without a manifest those contracts are the writer's choice, and a writer key can declare a contract of its own whose logs record loans that moved no money (see Security Considerations);
- SHOULD rebuild statements in full, against a manifest of the lender's contracts, before extending credit on the strength of them;
- MUST compute totals by adding up the statements'
closedfigures, per asset, never across assets.getSummaryaverages the values of the entries it matches; it does not add them up (measured on the deployed registry: two entries of 5 and 7 give a summary value of 6); - MUST report a writer it asked about that has no statement for the agent as having no data, not as a clean record;
- MUST choose which writers it trusts, as
getSummaryalready requires a list of client addresses. Anyone can deploy a contract and write statements about loans it made to itself (see Security Considerations); - SHOULD bound what it reads: entries per writer, log queries per rebuild, and the requests one read makes across every writer it reads. Anyone can post credit entries about an agent, and checking one costs requests (up to three per anchor), so a reader that reads writers it does not trust would otherwise pay for whatever was planted. A malformed entry, a failed check or a spent budget fails that writer only.
There are two levels of check:
- Light is a sample: the checks above, with a handful of calls per statement. It shows that nothing it checked is wrong. It does not show that the root covers every loan of a window, nor that the totals are right: a statement whose anchors are real and whose totals are invented passes every light check. It does not read the sources past the last window either: it says where the chain ends.
- Full rebuilds each statement from the logs of every source the chain declares (and every source of the reader's
manifest): its leaves,
leaves,root, the four totals,opened,outstanding, and that the anchors are the window's latest leaves. It scans each source over the whole chain, whatever itsblockssay, and on past the last window to the block it reads at. It needs the sources' logs for the agent and one block time per closed loan. Checking the money that moved for every loan costs one receipt per loan; the reference reader checks it on the anchors and default leaves only, and says so. Without a manifest naming the writer, a full rebuild shows that the statements match the logs of the sources the writer declares, not that those sources are the lender's.
Which sources a lender has is the reader's trust decision. Omission is visible within the sources a writer declares:
a full rebuild finds any loan the statements leave out at those sources, from the start of seq 1. No reader can find a
loan at a contract the writer never names. Nor, without a manifest, can it tell the lender's contracts from one the
writer adds: a source declared for the first time only needs its first block at or after its statement's fromBlock,
so a writer can declare a contract of its own, and its statements then rebuild exactly from that contract's logs. A
reader that knows a lender's contracts (from the lender's documentation, an audit, a registry it trusts, or the on-chain
associations below) MAY hold a manifest: those sources, with their first blocks, events and fields, for each writer it
names. With a manifest, the reader MUST also fail a writer when seq 1 starts after a manifest source's first block, when
a statement leaves out a manifest source whose blocks reach its window, or when a file declares a source the manifest
does not list or with other events or fields. A reader with a manifest MUST fail a writer it trusts that the manifest
does not name (reading that writer's sources as it declares them would drop every manifest check without a word), and
MUST say of each writer whether the manifest named it. It MAY then scan each source over its manifest range only.
On-chain manifests, a profile of ERC-8092
This part is optional. It lets a lender publish its manifest on chain, signed by both the lending contract and the writer, so a reader takes it from the chain instead of from the writer's files. It is a profile of ERC-8092 (Associated Accounts): each source is one ERC-8092 association, stored on chain.
The association. An ERC-8092 AssociatedAccountRecord with:
initiator: the lending contract (the source), as an ERC-7930 address on the storage contract's chain;approver: the writer, as an ERC-7930 address on the same chain;interfaceId:bytes4(keccak256("CreditSource(uint64,bytes)"));data:abi.encode(uint64 firstBlock, bytes source), wherefirstBlockis the source's first block (as in Sources) andsourceis the canonical encoding (see Encoding) of the JSON object{"events": …, "fields": …}holding the source'seventsandfieldsexactly as its files declare them. The record carries the declaration itself, not a hash of it, so a reader needs nothing but the chain;validAtat or before the block the record is stored in, andvalidUntilzero.
This part is the delegation schema ERC-8092 asks implementers of its Delegated key type to publish, selected by that
interfaceId.
Who signs for the lending contract. The lending contract signs with key type 0x8002 (ERC-1271)
when it implements it. Otherwise the record uses key type 0x0000 (Delegated), which under this interfaceId means:
initiatorSignature is a signature over the record's ERC-8092 EIP-712 hash by the lending contract's
ERC-173 owner() at the block the record is stored in, checked with ecrecover when that owner has no code and with
ERC-1271 when it has. A later change of owner() does not invalidate a stored record; the new owner may revoke it. The
writer signs as itself (0x0001 for a key, 0x8002 for a contract).
Storage. A storage contract implements ERC-8092's on-chain storage, plus a revocation function and two views:
interface ICreditSourceAssociations {
// ERC-8092 on-chain storage
event AssociationCreated(bytes32 indexed hash, bytes32 indexed initiator, bytes32 indexed approver, SignedAssociationRecord sar);
event AssociationRevoked(bytes32 indexed hash, bytes32 indexed revokedBy, uint256 revokedAt);
function storeAssociation(SignedAssociationRecord calldata sar) external;
function revokeAssociation(bytes32 hash, uint40 revokedAt) external;
// added by this profile
function sourcesOf(address writer) external view returns (bytes32[] memory associationHashes);
function getAssociation(bytes32 hash) external view returns (SignedAssociationRecord memory);
}storeAssociationMUST validate the record as ERC-8092 requires and as this part defines (theinterfaceId, thatdatadecodes as(uint64, bytes)with a non-emptysource, both addresses on this chain,validAtnot after the current block's time,validUntilzero,revokedAtzero, the Delegated rule above), MUST revert otherwise, and MUST emitAssociationCreated. BothinitiatorSignatureandapproverSignatureMUST be non-empty and valid for their key types: ERC-8092 checks a signature only when its field is populated, and a record missing the lending contract's signature would let a writer claim a contract that never agreed. It need not parsesource; readers check it.revokeAssociationMUST accept a call from the writer, from the lending contract, or from the lending contract's current ERC-173owner(), and MUST emitAssociationRevokedwith the stored time. The time is the later of the block's time and the time given; for an association already revoked, the earlier of that time and the one stored takes precedence, as ERC-8092 requires, so a second revocation can bring the end forward but never push it back. ERC-8092 requires that either party can revoke; the owner revokes for a contract that cannot.sourcesOf(writer)MUST return the hash of every association stored here whoseapproveriswriteron this chain, revoked or not, in the order they were stored. It covers this chain only: a source on another chain is associated in a storage contract on that chain.
Reading it. A reader MAY build its manifest for a writer from sourcesOf and getAssociation (or the
AssociationCreated events). Each association gives one source: the storage contract's chain, contract = the
initiator, blocks[0] = firstBlock, and the events and fields decoded from source. A reader MUST reject an
association whose source is not in canonical form or does not meet the rules of Sources. The signed record cannot
change after signing, so it carries no last block: a source lends until its association's revokedAt, and its last
block is the last block with a time at or before revokedAt. That includes the block the revocation lands in when it
takes effect at once: ERC-8092 treats the association as no longer valid from revokedAt, but loans in that block were
made while the writer spoke for the source, and a manifest range that ends early would hide them. An unrevoked
association still lends. The reader MUST re-check each record it uses (both signatures present and valid, and for the
Delegated type the owner at the storing block) rather than trust the storage contract, and MUST still choose which
writers it trusts: an association proves that a contract and a writer agreed, not that the lender is honest.
Where. ERC-8092 asks for a canonical storage location. This profile will name one storage contract per chain once one is deployed; until then a reader names the storage contract it reads.
Rationale
- A profile of the existing registry, not a new one. Every ERC-8004 deployment and every indexer of
NewFeedbackalready sees these entries. Credit events also live next to the agent's other reputation, andtag1is indexed, so a reader can find them with one log filter. - Statements, not one entry per loan. Per-loan entries cost 150,000 to 200,000 gas each and would bury an agent's other feedback. A statement costs the same as one entry and covers any number of loans. Defaults still get their own entry, so they stand out at once.
- Leaves name logs, not lender claims. Everything committed in a leaf is something the source's logs and block headers prove. Anything that cannot be proven in a standard way (the class, the lender's name) sits outside the hash and is marked as a claim.
- A Merkle root rather than a list. It keeps the file small, it can be rebuilt from logs, and it allows one-loan proofs on chain. The leaf encoding and pair hashing are OpenZeppelin's, so no custom verifier is needed.
- Events declared in the file. A reader needs no registry of lender adapters: the file says which signatures to read and which parameters hold the amount and due time.
data:URIs. The evidence is in the entry's own calldata, so it is immutable and needs no server or pinning. On the reference chain a 2.7 KB file posted for 198,000 gas (measured on a fork), which is a fraction of a cent.- Amounts and ids as strings. Base units of 18-decimal assets pass 2^53, and so can a
uint256agent or loan id. A reader that parses JSON numbers as floating point would read the wrong agent. - Canonical JSON. The hash fixes the bytes, not their meaning: with duplicate keys or other number spellings, two readers could read one file two ways. One canonical form leaves one reading, and a reader checks it by re-encoding what it parsed.
- Sources covered from their first block. A chain that started after a source's first loans, or a statement that dropped a source, would hide loans from a full rebuild. Requiring each declared source to be covered from its first block makes omission visible within the declared sources; the rest is the reader's trust decision.
- One asset per chain. Amounts in different tokens cannot be added. Keeping one asset per (writer, agent) chain keeps a chain's totals in one unit.
- On-chain manifests as ERC-8092 associations. ERC-8092 already gives a signed, revocable record that binds two
accounts, with a payload selected by an
interfaceId, so a manifest needs a payload and a lookup rather than a new registry. Both sides sign: a writer cannot claim a contract that did not agree, and a contract cannot be claimed by a writer its owner did not choose. Block bounds stay in the files and the logs, since a signed record cannot change after signing.
Open questions
- Tag names (
credit.statement,credit.defaulted), and whether a lender's summary score should also have a standard tag (credit.score). - Recovered amounts after a default: value seized or repaid later. Outcome 4 is reserved: a v1 file carries no
recovered leaf and a zero
recoveredtotal. A source would declare arecoveredevent, and a leaf would cite it. - Partial repayments and loans that roll over. For these and for recovered amounts: a v2 of the leaf, or separate entries?
- Sources on several chains in one statement. The reference reader reports a source on another chain as not rebuilt.
- Where manifests live, and how readers weight lenders. Is an ERC-8092 profile the right home for on-chain manifests, or should readers rely on a registry of lenders? On-chain manifests answer which contracts a writer speaks for; whether to trust the lender behind them is left open.
- An on-chain view that checks a Merkle proof against a statement's root. The root is in the file, not in registry
storage, so a contract needs the file's bytes plus the
feedbackHashfrom theNewFeedbackevent. - A sum view. Readers MUST NOT average credit entries with
getSummary, which averages values and does not add them. Should this profile ask registries for a sum view, or is the reader rule enough?
Backwards Compatibility
The profile adds tags and a file format; it changes nothing in ERC-8004. Readers that do not know the tags ignore them.
A lender's existing score entries are unaffected. ERC-8004's example feedback file writes agentId and value as JSON
numbers; files of this profile write them as decimal strings (see Encoding).
Test Cases
The test vectors that accompany the reference implementation hold:
- five leaves (on time, late, secured, defaulted) with their hashes, every proof and the root, for agent
9007199254740993(2^53 + 1) and with one loan id past 2^64, so ids read as floating point fail; - the empty root;
- a statement file with its exact canonical bytes,
feedbackHash,feedbackURIand thegiveFeedbackcalldata; - a default file with its exact canonical bytes,
feedbackHashandfeedbackURI.
A Solidity test generated from the same vectors recomputes the leaf hashes and verifies every proof with OpenZeppelin
MerkleProof. A forged leaf fails.
Reference Implementation
The reference reader, the format's code, the test vectors and their tests are in the authors' priors-agents/priors
repository:
sdk/credit-profile.mjs: leaves, Merkle root and proofs, statement and default files, the canonical encoding, the schema, and the reader's shape and chain checks. It is pure: it makes no network call.sdk/credit-reader.mjs: finds an agent's credit entries (a registry log scan, or entries located by the caller and checked againstgetLastIndex) and checks them light (sampled) or full (rebuilt, the sources also scanned past the last statement) against the chain, with an optional manifest and a budget of requests per read. A trusted writer the manifest does not name fails, and each writer says whether it was named. It says where each chain ends, and adds up by asset the rebuilt totals of trusted writers the manifest names.scripts/verify-credit.mjs: the reader as a command. It prints where each writer's statements end against the block it reads at, and counts only full results of writers the manifest names. Exit 0: every check it ran passed; 1: a problem; 2: no data.scripts/credit-vectors.mjs, thevectors.jsonfile it generates, andtest/CreditProfileVectors.t.sol: the test vectors, and the same leaves and proofs checked by OpenZeppelin'sMerkleProofin Solidity.scripts/test-credit-profile.mjs: unit tests of the format (18).scripts/test-credit-reader.mjs: the reader and the command against an in-memory chain served over JSON-RPC (22).
A writer and an end-to-end test run in the authors' own deployment, outside that repository. The end-to-end test runs the writer and the reader on a fork of Robinhood Chain, with the real registry and three real agents:
- The writer posts seq 1 and seq 2 for an agent with 252 loans across two pools: 138 loans in the first window, 114 in the second.
- The reader rebuilds both statements from the pools' logs and matches every root and total. It also checks the USDG movement of each anchor.
- The reader then catches each of these: a statement that drops one loan, which passes the light check (reported as sampled, not counted) and fails the rebuild; an anchor with an inflated amount; a default entry that cites a repayment's log; a window gap; a window reaching past the block the statement was posted in; and a revoked statement.
Security Considerations
- Self-dealing lenders. A contract can emit
BorrowedandRepaidfor loans it makes to an agent its owner controls, cycling the same money. Every check in this profile passes for such loans, because they did happen. Readers MUST therefore decide which writers to trust. Signals that help are the capital the lender has at risk across many unrelated agents, the lender's own default rate, and whether the lender's funds and the agent's funds come from the same place. On-chain manifests bind a writer to its contracts; how much to trust a lender is still the reader's call. - A lying writer. A writer key can post a false statement, but it cannot remove an earlier one. A false root fails a
full rebuild. A skipped window or a dropped default at a declared source leaves a gap or a mismatch. A writer that
stops posting leaves its record at its last window: readers say where that is, and a full rebuild finds a default
after it that no default entry reports within 24 hours. A source the writer never declares is invisible to a reader
without a manifest. To that reader, a source the writer does declare is the lender's. The writer key, the lender's or
a leaked one, can deploy a contract that emits
BorrowedandRepaidfor any agent, declare it as a new source, and post statements that rebuild exactly from its logs. Money moves for the anchors only, out and back. Only a manifest, taken from somewhere other than the writer's files, binds a writer to its lender's contracts; with one, such a source fails the writer. Readers therefore count rebuilt totals only against a manifest (see Readers), and a lender that wants its statements counted publishes one for its writer. - Light checks are samples. A statement with real anchors and invented totals passes them. A light result is the writer's claim, and readers MUST NOT count it as checked.
- A lying source contract. Logs prove what a contract emitted, not that it is honest. The funds check ties the sampled loans to actual transfers of the asset; a contract can still emit loan events with no money behind the others. Readers relying on a source should know its code (verified source, audits), and hold it in a manifest.
- Reorgs. A statement over unfinalised blocks can name logs that later disappear. Windows SHOULD end at final blocks.
- Late versus on time follows block timestamps, so a loan repaid within a few seconds of
dueAtcan fall either side. Lenders SHOULD keep their own lateness rule consistent withclosedAt <= dueAt. - Privacy. Every amount and address in a statement is already public in the source's logs. Files MUST NOT carry personal or off-chain identity data. Credit scoring of people is regulated in many places (for example as high-risk under the EU AI Act). This profile records machine-to-contract loans only.
- Endpoints. Full rebuilds read many logs. Public endpoints may limit log ranges or history, so a reader may need an archive endpoint. A writer's windows and sources decide how many log queries a rebuild needs, so readers SHOULD cap them.
- Planted entries.
giveFeedbackis open to anyone, so anyone can post valid-looking credit entries about an agent, from as many addresses as they like, each costing a reader that checks it a few requests per anchor. A reader that names the writers it trusts never reads the others. A reader that reads every writer SHOULD cap the requests of one read, all writers together (the reference reader: 20,000).
Copyright
Copyright and related rights waived via CC0.
