A ground-truth technical walkthrough of the whole underwriting pipeline: how revenue is discovered, how the Sybil/independence tracker judges it, how the score and credit line are computed, and how the on-chain lending contract enforces it. Written from the code, not the pitch. Numbers are the current testnet-calibrated values.
0. The one-sentence version
An AI agent’s stable Stellar address earns USDC → TrustLine reads that revenue off-chain, discounts anything that looks self-dealt or fake (the moat) → turns the surviving “real, independent” revenue into a score and a credit tier → an on-chain vault lets the agent borrow against that tier, with defaults, reserves and rate mechanics enforced by the contract. Four layers, in order of how data flows:- Revenue discovery — what did this agent actually earn, on-chain?
- Independence engine (the Sybil tracker) — how much of that is real?
- Scoring — turn real revenue + history into a 0–850 score and a tier.
- On-chain credit engine — the vault that actually lends, at the tier’s terms.
1. Revenue discovery — and the honest answer about “x402”
1.1 How does it know a transaction was an x402 payment?
It doesn’t — and it doesn’t need to. This is the part everyone gets wrong, so it’s worth being blunt. x402 is a payment protocol: a client requests a resource, gets an HTTP402 Payment Required, pays, and re-requests. But when that payment settles on Stellar, it lands as an ordinary USDC transfer — byte-for-byte indistinguishable on-chain from any other USDC transfer. There is no on-chain “this was x402” flag, tag, or memo the engine could key off.
So the engine doesn’t look for one. It treats any USDC received by the agent, from a distinct non-excluded counterparty, as revenue. Two direct consequences:
- The system is x402-agnostic. It would score revenue from any USDC inflow, whether it came through x402 or a plain wallet-to-wallet payment. “x402 revenue” in the pitch really means “USDC the agent earned from independent payers.”
- To avoid counting x402 infrastructure as a customer, the engine keeps an exclude list (
X402_EXCLUDE_ADDRESSES). x402 settlement transactions involve a facilitator (OZ Channels — the submitter) and a fee sponsor; those addresses show up in the transaction as the source / fee-payer, but they are never the genuine payer. The real payer is thefromfield of the USDC transfer itself (the customer’s own wallet). The exclude list currently holds: the facilitator submitter, the fee sponsor, a testnet seeder wallet, and TrustLine’s own vault contract addresses.
1.2 What a USDC transfer looks like to the engine
Every USDC movement — x402 or not, contract or classic — surfaces as a SACtransfer event on the USDC token contract:
amount over all events where: to == agent and from != agent and from is not in the exclude list. Each distinct from is one payer (counterparty).
1.3 Three sources of history (it uses whichever sees the most)
An agent’s real payments can be recent or months old, so the engine reads from three places and takes whichever reports the most revenue:
The Horizon path is the deep one: Horizon retains full account history indefinitely and exposes the raw
invoke_host_function call parameters for every historical Soroban transfer (decodable with the same scValToNative used everywhere), plus classic payment operations. It’s only invoked when the two fast sources both come up “thin” (< 0.5 USDC), so a normal underwrite stays fast, but an agent whose real activity predates our window isn’t wrongly scored as having zero revenue.
2. The independence engine — the Sybil tracker (the moat)
This is the differentiated part. Reading inflows is aSELECT. The hard, defensible question is: is this revenue from genuine independent customers, or is the operator paying themselves to manufacture a credit score? If that can be faked cheaply, the whole protocol is insolvent by construction.
Source of truth: backend/src/scoring/independence.ts, spec in Sybil / independence model.
2.1 The core idea: effective independent revenue (R_eff)
Raw revenue is not what feeds the score. Each payer i gets an independence weight w_i ∈ [0,1], and the revenue is re-weighted:
R_eff — not raw revenue — is what the scorer consumes. A payer scoring w_i = 0 contributes nothing, no matter how much “revenue” it sent.
2.2 The four per-payer factors, in detail
① Age factor — is this a real, established wallet or a throwaway?- Account age comes from Horizon’s first-ever operation for the wallet (true creation date, all-time — not limited to our window), OR the DB’s first-seen record; it takes the larger (older) of the two.
- A wallet born this week counts less; a brand-new wallet counts ~0. Defeats the fresh-wallet-farm attack (spin up N new wallets to “pay” yourself).
- Note:
AGE_FULL_DAYSis 2 for testnet (the whole chain is only days old); a mainnet-scale value would be ~30. Overridable viaINDEP_AGE_FULL_DAYS.
external_out_degree= the number of distinct other counterparties the payer transacts with (both directions), excluding the agent itself AND the agent’s other payers. That co-payer exclusion is deliberate — it stops a tight ring of wallets from looking “diverse” just because they trade among themselves.- A wallet whose only counterparty is this agent is a puppet → factor ~0. Defeats the self-pay attack (aged wallets that only ever pay you).
- This walks the payer’s USDC funding ancestry up to
MAX_HOPS = 3hops backward and asks: did this money originate from the agent? Ifagent → … → payer → agent, the payer is circular and its revenue is hard-zeroed. This is the signature attack (A3): fund some wallets, have them “pay” you, repeat. - Computed two ways depending on data source: a k-hop reverse search over RPC
getEvents, or (with the Postgres graph) a single recursive SQL query — same result, much faster. - Known-hub addresses (faucets, facilitators) are not traversed through, so two honest agents funded by the same faucet don’t look linked.
- Real customers don’t get paid by the seller. If the agent pays a payer roughly what that payer pays the agent, the net revenue is ~0. This kills a mutual collusion ring (A7a): K real operators cross-paying to inflate each other.
agent_paid_to_payeris summed from actualagent → payertransfers.
2.3 The two whole-portfolio factors
⑤ Concentration cap + HHI — is one “customer” secretly the whole business?- No single payer can contribute more than 40% of counted revenue (the cap), and a portfolio dominated by 1–2 payers gets a low
concentration_factorvia a normalized Herfindahl index. A long tail of independent payers → HHI low → factor ≈ 1. Two payers → HHI high → factor → 0. Defeats concentration (A4).
- Based on the coefficient of variation of inter-payment intervals: perfectly regular (scripted) cadence trends toward the floor; irregular/bursty (real) trends to 1. It’s a soft signal — floored at 0.5 so it can flag but never hard-fail legitimate steady revenue. Flags synthetic cadence (A5).
2.4 The attack catalog — what’s caught, what isn’t (honest)
A payer counts as a listed “independent counterparty” once
w_i ≥ 0.5 (INDEPENDENT_WEIGHT_THRESHOLD). Verified against this whole catalog by npm run test:independence, and against a real on-chain circular attacker.
3. Scoring — from real revenue to a 0–850 score and a tier
Source:backend/src/scoring/index.ts. Inputs: R_eff (independent on-chain revenue), distinct-payer count, any zkTLS-proven off-chain revenue, and on-chain repayment history.
3.1 Effective revenue
- On-chain revenue only counts once there are ≥ 3 distinct independent payers — a cheap anti-Sybil floor on top of the independence engine.
- zkTLS-proven off-chain revenue (a private Stripe balance, cryptographically proven without exposing the key) is trusted 1.5× — faking a real Stripe account is harder than faking wallets.
3.2 Revenue → base score (bands)
D = SCORE_BAND_DIVISOR (= 1000 on testnet, so a few USDC clears a tier; = 1 for mainnet $-scale). Plus a counterparty-diversity bonus: min(distinctPayers, 10) × 5 (up to +50).
3.3 Repayment history adjustment
score_registry.get_repayments, so it’s the same record the on-chain credit ramp uses (§4.3).
3.4 Score → tier
4. The on-chain credit engine — the lending vault
Source:contracts/lending_vault + contracts/libraries/revenue_math. This is what actually holds lender USDC and lets the agent borrow. Everything is per-agent isolated: a lender deposits into one agent’s vault; that agent’s default can never touch another agent’s lenders.
4.1 Tier → limit and rate (the policy, in revenue_math)
4.2 Dynamic APR (utilisation-based)
The rate an agent actually pays rises as the vault gets drawn down:4.3 Credit ramp — a cold agent can’t jump straight to a big line
Even at Tier B (2× limit), a brand-new agent can only draw a fraction of that limit; it grows only with proven repayment:value_unlocked for a cold attacker — the economic-security lever. Enforced in the contract’s borrow, and mirrored in the credit_line terms view so the advertised limit matches what the vault allows.
4.4 Repay, reserve, and lender yield
On repayment, interest is paid first and split: 20% into a per-vault first-loss reserve (RESERVE_CUT_BPS), the rest becomes lender yield (claimable pro-rata by shares). Principal returns to lendable liquidity.
4.5 Default lifecycle
- The first draw from a zero balance starts a clock (
now + TERM; deployed at 300s on testnet for fast demoing). It isn’t extended by further draws and clears on full repayment. - Past the due date, anyone can call
mark_default(agent)— permissionless, pure deadline math, no trust added. - On default: the reserve absorbs the loss first; the unrecovered remainder is socialised to the vault’s lenders via share-price drop (no per-lender iteration needed — it’s shares-based accounting); the agent is frozen out of further borrowing. Off-chain, the engine records the miss (§3.3), collapsing the score.
4.6 Safety rails
- Pause / unpause (admin): halts new deposits and borrows only — repay/withdraw/claim-yield/mark-default always work, so pausing can never trap funds.
- Deposit cap (admin-adjustable, deployed at 10,000 USDC/vault): caps the blast radius of any undiscovered bug.
- Proven by 40 Rust tests including a 1,500-step randomized invariant fuzz test asserting
token.balance(vault) == liquidity + reserve + yield_poolafter every random action.
5. End-to-end: one underwrite, start to finish
1
Discover revenue
Read USDC transfers to the agent from RPC + Postgres graph + (if thin) Horizon; keep whichever sees most. Exclude facilitators / fee-sponsor / vaults. (§1)
2
Judge independence
For each payer compute age · diversity · not-funded · reciprocity, apply concentration + organicity →
R_eff and a per-payer “why counted / why rejected” breakdown. (§2)3
Score
R_eff (+ zkTLS off-chain ×1.5, + repayment history) → bands → 0–850 → tier. (§3)4
Publish
The trusted signer publishes the signed score to
score_registry on-chain; the tier drives credit_line terms and the vault’s borrow check. (§3–4)5
Borrow / repay / default
The agent draws against its ramped limit at the dynamic APR; repayments feed the reserve + lender yield + on-chain repayment record; misses trigger the default lifecycle. (§4)