Autonomous agents are starting to create real, verifiable on-chain value — a RealClaw bot trading on Mantle, an agent earning fees over x402. But they have no portable creditworthiness and no safe way to be trusted with capital. A profitable agent cannot borrow against its track record. A compromised agent key can drain a wallet. Identity exists (ERC‑8004) and payments exist (x402), but nothing binds them into an economic trust layer.
The swing is that layer. An agent earns a portable, recency-decayed, Sybil-gated reputation from its real on-chain track record. That reputation gates access to capital: larger, partially- or under-collateralized credit lines drawn from an ERC-4626 lender vault. The capital is deployed through a spending-controlled smart account whose guard rejects rogue transactions on-chain — per-tx caps, rolling daily limits, allowlists. Agents pay each other for services over x402, and proof-of-payment loops back as reputation.
| Layer | What it is | Where it lives |
|---|---|---|
| Identity | ERC-8004 agentId per agent; agentWallet → the smart account | ReputationOracle keys |
| Reputation | Off-chain engine scores PnL + signals → R ∈ [0,1000], committed on-chain with an evidence hash | apps/engine + ReputationOracle |
| Credit | ERC-4626 lender pool + a CreditManager opening reputation-tiered lines | contracts/ |
| Safety | One Solidity guard library, used by both a direct GuardedAccount and ERC-7579 modules | contracts/src |
| Payments | x402 agent-to-agent settlement; proof-of-payment loops back as feedback | apps/engine |
| Reasoning | Z.ai GLM narrates each credit decision in plain language | apps/engine |
The signature beat is the last box. A compromised key tries to drain the guarded account to a non-allowlisted address; the spend is rejected inside the EVM and the transaction mines as a reverted, status-0 transaction on the Mantle explorer. This is the difference between telling an agent not to misbehave and making it impossible.
The Agentic Economy track rewards integration depth, agent autonomy, verifiability, and demo quality. The swing maximises all four at once: it uses ERC-8004 (identity), Byreal/RealClaw (the track requirement), x402 (the payments rail the EIP points to), AA-style spending controls (a visceral safety story), and Mantle DeFi — with every score, credit decision, and rejection landing as an on-chain event. It is infrastructure, not a single bot.
Live on Mantle Sepolia (chainId 5003): all 7 spine contracts deployed and
source-verified on Mantlescan, 60 passing Foundry tests,
7 passing engine scoring tests. The full north-star loop has been executed on-chain
with real transactions — reputation commit (R=832 → T3, via a 2-of-3 committee), credit draw (5,000 USDC),
allowed spend (status 1), and a rogue spend that reverted on-chain (status 0).
On top of the spine sit two real product entry points — lend on /vault and
launch an agent on /launch — both wallet-connected and user-signed.
The rest of this book walks the codebase from the chain up: the contracts that are the source of truth, the engine that projects reputation onto them, and the frontend that turns the whole loop into something a judge can watch happen.
limit = BASE · (R/1000)^1.5 and why the exponent is 1.5Foundry contracts are the spine; the engine and dashboard are projections of them.
The most important consequence of that decision lives in one pure library:
TierMath.sol. It defines the entire economic relationship between a number
(an agent's reputation R) and a permission (how much capital it can touch, how fast, and against
how much collateral). Get this curve right and the rest of the protocol is bookkeeping.
A score in [0,1000] maps to one of four tiers. The boundaries are fixed in
tierOf(uint16 score):
| Tier | Score range | Stance | Collateral factor | Per-tx cap | Daily limit | Tier cap |
|---|---|---|---|---|---|---|
| T0 Unproven | 0–249 | over-collateralized | 150% | $25 | $50 | $100 |
| T1 Emerging | 250–499 | fully collateralized | 100% | $250 | $500 | $1,000 |
| T2 Established | 500–749 | 50% uncollateralized | 50% | $2,000 | $5,000 | $10,000 |
| T3 Trusted | 750–1000 | uncollateralized | 20% | $10,000 | $25,000 | $50,000 |
All amounts are 6-decimal USDC. The collateral factor is the borrowing-power
multiplier the CreditManager applies; a lower factor means the same collateral unlocks
more credit (and at T3, credit is granted with no collateral at all).
Within a tier’s band, the credit limit grows faster than linearly in reputation, so a great agent is meaningfully rewarded over a merely good one — but it is clamped to the tier cap, so growth is never unbounded:
// TierMath.sol — pure, mirrored in packages/shared
uint256 constant BASE_LIMIT = 50_000e6; // ceiling, 6-decimal USDC
int256 constant GAMMA_WAD = 1.5e18; // the exponent in (R/1000)^1.5
uint16 constant SCORE_MAX = 1000;
function creditLimit(uint16 score) internal pure returns (uint256) {
// limit = BASE_LIMIT * (score / 1000) ^ 1.5, then clamp to tierCap(tier)
uint256 raw = _powWad(score, GAMMA_WAD) * BASE_LIMIT / 1e18;
uint256 cap = tierCap(tierOf(score));
return raw < cap ? raw : cap;
}
Why 1.5 and not 2.0 or 1.0? A linear curve under-rewards the top of each band; a quadratic curve makes the jump from “good” to “excellent” so steep it dwarfs the tier structure. 1.5 is the compromise that keeps the tier the hard ceiling while still paying a real premium for reputation inside it. An agent at R=832 has borrowing power around $37,945 — the exact number the on-chain demo draws against.
The same math lives in packages/shared as a TypeScript mirror so the engine and the
dashboard can show a limit before a transaction lands, and the contract can enforce the
identical number when it does. The shared package ships a generated set of ABIs and a tier-math
mirror; if the Solidity changes, the mirror is regenerated. The contract is always the authority —
the mirror exists only to make the UI honest.
Reputation struct and why it carries an epoch and an evidence hashThe heavy lifting of reputation — recency decay, Sybil gating, cohort
normalization — happens off-chain in the engine (Chapter 7). The ReputationOracle is
the cheap, trustless read of the result. The engine computes R, then commits it here so that
the CreditManager and the dashboard can read a single number without re-running the model.
struct Reputation {
uint16 score; // R in [0,1000]
uint8 tier; // 0..3, derived via TierMath.tierOf at commit time
uint64 updatedAt; // block.timestamp of last commit
uint32 epoch; // monotonic; increments every recompute
bytes32 evidenceHash; // keccak of the exact scoring inputs
}
// k-of-n committee: needs `threshold` signatures from distinct authorized signers over a
// digest bound to (chainId, this oracle, agentId, score, evidenceHash, nextEpoch).
function commit(uint256 agentId, uint16 score, bytes32 evidenceHash, bytes[] calldata sigs)
external // permissionless to submit
{
require(score <= SCORE_MAX, "score");
uint32 nextEpoch = _rep[agentId].epoch + 1;
bytes32 ethHash = toEthSignedMessageHash(_digest(agentId, score, evidenceHash, nextEpoch));
address last; uint256 valid; // recovered signers must strictly ascend (dedup)
for (uint256 i; i < sigs.length; ++i) {
address rec = ECDSA.recover(ethHash, sigs[i]);
require(rec > last && isSigner[rec]); // sorted + authorized
last = rec; ++valid;
}
require(valid >= threshold); // quorum reached
Reputation storage r = _rep[agentId];
r.score = score; r.tier = TierMath.tierOf(score);
r.updatedAt = uint64(block.timestamp); r.epoch = nextEpoch; r.evidenceHash = evidenceHash;
emit ReputationCommitted(agentId, score, r.tier, nextEpoch, evidenceHash);
}
Three reads are exposed for the rest of the protocol: getReputation(agentId) returns
the full struct; scoreOf(agentId) and tierOf(agentId) are the hot paths the
CreditManager uses when sizing a line.
The oracle is a k-of-n signer committee: a commit needs threshold
signatures from distinct, owner-managed signers over the digest above, and submitting the transaction
is permissionless — the quorum’s signatures carry the authority, not the sender. So
no single key can fabricate a score, and a quorum’s signatures cannot be replayed across chains,
deployments, or epochs (all three are baked into the digest). It deploys 1-of-1 for dev and 2-of-3 for
the demo, and the engine signs each commit with a quorum of the committee keys. This is what makes
“you can’t self-assign reputation” literally true — a user cannot call
commit for their own agent. The operator-onboarding flow in Chapter 14 routes through this
same gate, which is why the dashboard says the engine attests a starter score.
The first /onboard call reverted with a 500. The engine derived an agentId from the
owner address and packed it for the commit, but a non-checksummed test address tripped viem’s
encodePacked address validation. The fix was one line —
getAddress(owner.toLowerCase()) to normalize the checksum before encoding. The lesson:
addresses crossing the JS/EVM boundary must be checksum-normalized at the boundary, not assumed.
The epoch field is the quiet workhorse. It increments on every recompute, so the engine
can detect a stale or replayed commit, and the dashboard can show “epoch N” as a freshness
signal. The evidenceHash binds the commit to the exact inputs that produced it —
anyone can recompute the score from those inputs and check the hash matches. Reputation that can be
re-derived is reputation you do not have to trust.
ERC-4626 is designed for atomic deposit and redemption, not for asynchronous,
under-collateralized lending. So the swing keeps two contracts: CreditVault is a clean,
composable lender pool; CreditManager sits on top of it and runs the reputation-gated
credit logic. Lenders only ever touch the vault; agents only ever touch the manager.
A standard OpenZeppelin ERC-4626 vault over MockUSDC (the demo asset). Lenders deposit and receive
scUSDC shares; the share price rises as borrowers repay interest into the idle balance.
totalAssets() is idle balance plus totalBorrowed, so interest accrues to
lenders implicitly — there is no explicit yield-distribution call.
The classic ERC-4626 inflation/donation attack — where a first depositor donates assets to
skew the share price — is mitigated two ways at once: a decimals offset of 6
(virtual shares, so the attacker can never own the entire supply) and a dead-shares seed
at deploy (1 USDC deposited to address(0xdead)). The sole borrow authority is the
CreditManager:
uint8 constant DECIMALS_OFFSET = 6; // virtual shares
// at deploy: vault.deposit(1e6, 0xdead) // dead shares
function borrow(address to, uint256 amount) external onlyCreditManager { ... }
function onRepay(uint256 principal) external onlyCreditManager { ... }
function writeOff(uint256 principal) external onlyCreditManager { ... } // on liquidation
The manager reads tier from the oracle, opens a line bound to a specific smart account, and disburses
drawn capital only to that account — never to an arbitrary address. The Line
struct snapshots the tier and APR at open time and tracks principal, accrued interest, and collateral.
| Function | Who | What it does |
|---|---|---|
openLine(agentId, account) | permissionless | Snapshot tier & APR, set limit, bind the account |
draw(agentId, amount) | permissionless | Accrue interest, check borrowing power, disburse to the bound account only |
repay(agentId, amount) | permissionless | Repay interest first, then principal; tokens flow to the vault |
postCollateral / withdrawCollateral | permissionless / owner | Manage collateral held by the manager |
refreshLimit(agentId) | permissionless | Re-read the oracle and re-size the limit when R crosses a tier |
liquidate(agentId) | onlyOwner | Write off principal, seize collateral, mark liquidated |
The permissionless-everywhere-but-liquidate design is deliberate: it is what makes the user-signed operator-launch flow in Chapter 18 possible. A user can open their own line and draw their own credit directly from their wallet, because the protocol never needed a privileged caller for the happy path.
fromCollateral = collateral * 1e4 / collateralFactorBps(tier);
uncollateralized = tier == T3 ? limit : tier == T2 ? limit / 2 : 0; // T0/T1 → 0
power = min(fromCollateral + uncollateralized, limit);
So a T3 agent borrows up to its full limit with zero collateral; a T2 agent gets half its limit uncollateralized plus whatever its collateral supports; T0/T1 must post collateral for everything.
APR is an Aave/Compound-style utilization curve plus a reputation spread — worse reputation pays more:
BASE_APR_BPS = 200; // 2% baseline
KINK_BPS = 8000; // 80% utilization kink
SLOPE1_BPS = 400; // gentle below the kink
SLOPE2_BPS = 6000; // steep above it
// tier spread: T0 +2000bps, T1 +1000, T2 +500, T3 +200
Interest accrues lazily — only when draw, repay, or
refreshLimit is called — so there is no per-block bookkeeping. Default is expensive in
reputation terms: a liquidate emits an event the engine reads to post a negative reputation
signal, which is the only enforceable “collateral” in a pseudonymous setting.
GuardedAccount — the bundler-independent path that guarantees the demoThis is where the demo lives. The CreditManager decides how much
capital an agent has; the SpendingGuard decides where it may go and how fast.
A compromised key that tries to drain the account to an attacker is stopped here — and the
rejection is a real, mined, status-0 transaction on the Mantle explorer.
All the policy logic lives in one pure library, SpendingGuardLib.sol, ported from the
Solana ancestor’s policy.rs. The order of checks is load-bearing — it determines
which typed error fires first:
Frozen()DestinationNotAllowedPerTxCapExceededDailyLimitExceededenforce() runs it and, on success,
rolls the 24h window and records the spend. preview() mirrors it read-only for off-chain
pre-flight, returning a reason code without writing.struct Config { uint256 perTxCap; uint256 dailyLimit; bool frozen; } // 0 = unlimited
struct Window { uint64 start; uint256 spent; }
function enforce(Config storage cfg, Window storage win, bool destAllowed,
address dest, uint256 amount, uint256 nowTs) external {
if (cfg.frozen) revert Frozen();
if (!destAllowed) revert DestinationNotAllowed(dest);
if (cfg.perTxCap != 0 && amount > cfg.perTxCap)
revert PerTxCapExceeded(cfg.perTxCap, amount);
if (nowTs - win.start >= 1 days) { win.start = uint64(nowTs); win.spent = 0; } // roll
if (cfg.dailyLimit != 0 && win.spent + amount > cfg.dailyLimit)
revert DailyLimitExceeded(cfg.dailyLimit, win.spent, amount);
win.spent += amount; // record on success
}
The same library backs two account implementations, so the rogue-reject behaves identically no matter which one a deployment uses:
GuardedAccount.sol — a minimal smart wallet called directly from
an EOA or relayer. No bundler, no paymaster. Its execute() decodes the spend, runs
enforce(), then calls the target. This is the guaranteed demo path: even if ERC-4337
infrastructure on Mantle is down, the revert still happens.SpendingGuardValidator (Type 1, a scoped
agent session key) and SpendingGuardHook (Type 4, runs the ladder in preCheck
and reverts the UserOp on breach) for modular accounts like Kernel or Safe.Spend metering is shared too, via SpendDecodeLib: it resolves the effective
destination and amount of an outgoing call. For native value, that is the call target and
msg.value; for an ERC-20 transfer/approve/transferFrom
it extracts the token recipient and amount. So “transfer USDC to an attacker” is
caught even though the call target is the USDC contract, not the attacker.
A reverting transaction normally never reaches the chain: viem runs eth_estimateGas
first, which throws, and the client aborts before broadcasting. That would have made the rogue-reject
invisible. The fix is to pass an explicit gas limit (gas: 300_000n) on
the spend, which skips estimation and lets the transaction mine as status 0 — carrying
the guard’s typed error in the receipt. The reverted transaction is the proof. This trick lives
in the engine’s spend.ts (Chapter 10).
A GuardedAccountFactory deploys accounts deterministically via CREATE2, with salt
keccak256(agentId, owner), so the engine and dashboard can predict() an
account’s address before it exists — which is exactly what the operator-launch stepper does
to show the user where their agent will live before they sign the create.
The ERC-7579 modules are not a diagram — they run. pnpm --filter @swing/engine aa-demo
(and the dashboard’s Spending guard · ERC-7579 panel) spin up a real
Kernel v3.1 smart account on Mantle Sepolia, install the
SpendingGuardValidator + SpendingGuardHook, and drive spends through a real
ERC-4337 bundler with gas sponsored by Pimlico’s paymaster — the owner key
never holds a wei. The agent’s session key (the validator) drives the account; every spend it makes
is metered by its attached hook. An allowed, in-bounds payment to the allowlisted merchant mines;
a rogue one is refused, carrying the same typed error (DestinationNotAllowed,
PerTxCapExceeded) the ladder always throws. The engine exposes it over
/aa/state, /aa/prepare, /aa/spend off a shared
aa.ts core.
There is one honest difference in where the rejection surfaces. The
GuardedAccount path mines a status-0 transaction — the revert is on the chain. On the
account-abstraction path, a UserOp whose execution would revert is rejected by the bundler at
simulation: it never enters the mempool. So the guard makes the malicious operation
un-bundleable rather than mined-and-failed — same policy, same SpendingGuardLib,
enforced one layer up, surfaced at the bundler instead of in a block.
Wiring a custom hook onto a real modular account is a gauntlet of undocumented detail, and the chain
gives almost no feedback — Mantle’s public RPC blocks tracing, so a wrong move just
succeeds and does nothing. Five things had to be exactly right: (1) the validator-with-hook
install data is three ABI segments, not the two a popular SDK emits; (2) Kernel slices a leading
flag byte off the hook’s init data, so the policy must be prefixed with 0xff or it
decodes to garbage; (3) the UserOp nonce must carry validation-type 0x01 to route to our
validator — the SDK’s helper emits 0x00, which Kernel reads as sudo and
quietly runs the owner key with no hook at all; (4) the validator must be permitted for the
inner execute selector via allowedSelectors, or validation reverts
InvalidValidator(); and (5) the callData must be prefixed with the executeUserOp
selector so the EntryPoint takes the one path that runs the hook. The bug that cost the most, though, was
ours: passing the recipient as the call target, so the account called transfer() on
an EOA — a no-op that returns success. Found by decoding the on-chain UserOperationEvent
and reading balances, because nothing ever reverted.
Deploy.s.sol deploys and how it seeds the vaultThe spine is seven contracts, deployed to Mantle Sepolia and source-verified so a judge
can read the code at sepolia.mantlescan.xyz. Two configuration choices made this work; both
are worth knowing because both cost time to discover.
// foundry.toml
[profile.default]
solc = "0.8.28"
evm_version = "cancun" // OZ 5.6 emits MCOPY; Mantle supports Cancun
optimizer = true
optimizer_runs= 200
bytecode_hash = "none"
[etherscan]
mantle_sepolia = { url = "https://api.etherscan.io/v2/api?chainid=5003", key = "${MANTLESCAN_API_KEY}" }
mantle = { url = "https://api.etherscan.io/v2/api?chainid=5000", key = "${MANTLESCAN_API_KEY}" }
The evm_version = cancun line matters: OpenZeppelin 5.6 emits the MCOPY
opcode, and Mantle’s OP-Stack supports it — but it had to be re-confirmed on Sepolia before the
demo deploy. The [etherscan] URLs point at the Etherscan V2 unified multichain
endpoint; the old per-host Mantlescan V1 URLs are dead, and discovering that was a half-day
detour.
script/Deploy.s.sol deploys all seven contracts and seeds the vault in one run:
MockUSDC, ReputationOracle (with the oracle signer), CreditVault (asset = MockUSDC,
scUSDC), CreditManager (oracle + vault), GuardedAccountFactory, and the two ERC-7579
module singletons. It then mints 1.1M USDC to the deployer, seeds 1 USDC of dead shares to
0xdead, and deposits 100k USDC as demo liquidity. It writes every address to
deployments/5003.json.
In a sandboxed environment, forge script --broadcast’s fork backend could not hold
a stable connection to the public RPC. The reliable path was deploying contract-by-contract with
forge create plus retries — single sequential transactions. From a normal terminal,
the one-command forge script deploy works fine.
All eight contracts — the seven spine contracts plus agent #1’s deployed
GuardedAccount — are source-verified on Mantlescan using the Etherscan V2 unified
endpoint (--verifier-url "https://api.etherscan.io/v2/api?chainid=5003"). The test suite is
60 passing tests across nine files: TierMath.t.sol,
ReputationOracle.t.sol, CreditVault.t.sol, CreditStack.t.sol,
CreditManager integration, GuardedAccount.t.sol,
GuardedAccountFactory.t.sol, and SpendingGuardModules.t.sol — the
rogue-tx revert is tested on every rung of the ladder.
The engine is Node/TypeScript. Its heart is scoring.ts — a pure,
deterministic function from an agent’s history to a number in [0,1000]. Pure and
deterministic are the whole point: anyone can re-run it and get the same answer, which is what makes the
on-chain evidence hash meaningful.
| Symbol | Input | Normalization |
|---|---|---|
zPnl | risk-adjusted realized PnL | z-score vs cohort, clipped [-3, 3] |
win | win rate | λ-weighted mean ∈ [0,1] |
oneMinusDd | 1 − max drawdown | [0,1], higher is better |
consistency | Sharpe-like mean/σ of returns | squashed to [0,1] |
jobs | completed jobs / validations | log-scaled to [0,1] |
validation | avg validation response | /100 |
age | account age & continuity | log-scaled [0,1] |
sybil | Sybil penalty from passport | (15 − passport)/15, [0,1] |
// scoring.ts — λ = 0.5^(ageDays / 30): a 30-day half-life on every event
const raw =
0.30 * zPnl + 0.15 * win + 0.15 * oneMinusDd + 0.15 * consistency +
0.08 * jobs + 0.10 * validation + 0.07 * age;
let R = 1000 * sigmoid(1.2 * raw); // K = 1.2 tunes the spread
R = R * (1 - 0.5 * sybil); // multiplicative Sybil gate
// hard floor: thin history or weak passport can never exceed T0
if (passportScore < 15 || trades.length < 5) R = Math.min(R, 249);
Every aggregate is a λ-weighted mean, so an event from 30 days ago counts half as much as one today, and one from 60 days ago a quarter. The score therefore reacts immediately to new on-chain activity and lets old losses decay away — a strong recent run can lift an agent into T3, while a long-dormant or thinly-traded account is held at T0 no matter how good its handful of trades look.
evidenceHash = keccak256(JSON.stringify({
v: 1, score, weights, k: 1.2, halfLifeDays: 30,
trades, validations, jobsCompleted, accountAgeDays, passportScore, now,
})); // committed on-chain alongside the score
This is the link back to Chapter 3. The hash is a canonical fingerprint of every input plus the result. Commit it on-chain and the reputation becomes recomputable: a sceptic feeds the same inputs to the same public function, hashes them, and checks the on-chain commit. Seven vitest cases lock the behaviour — recency halving, T3 promotion for strong agents, the T0 floor for weak passports and thin history, decay of old losses, ranking, and hash determinism.
oracle.ts turns a computed score into a signed on-chain commitoracle.ts is the seam between the pure scorer and the chain.
commitScore(agentId, inputs) computes the score off-chain, then sends
ReputationOracle.commit(agentId, score, evidenceHash) with the engine’s signer and
waits for the receipt. commitRaw(agentId, score, evidenceHash) is the same write with a
pre-computed score — the path operator-onboarding uses to attest a starter reputation.
chain.ts builds the public client, the wallet client (only if a signer key is set), and a
set of typed contract instances. One detail is load-bearing:
// chain.ts — Mantle's public RPC rejects default concurrent batches.
export const publicClient = createPublicClient({
chain: mantleSepoliaTestnet,
transport: http(RPC_URL, { batch: false, retryCount: 10, retryDelay: 600, timeout: 25_000 }),
});
batch: false forces sequential JSON-RPC calls. Without it, viem coalesces reads into a
single batched request and Mantle’s public endpoint intermittently rejects them; with sequential
calls and retries, reads are reliable. The registry scanner (Chapter 9) reads ids one at a time for the
same reason.
The typed instances are the engine’s read surface: oracle (getReputation, commit),
manager (getLine), vault (totalAssets, totalBorrowed, availableLiquidity),
usdc (balanceOf, transfer, mint), hook (previewSpend), and a
guardedAccountAt(address) factory. Everything downstream — the API, the indexer, the
spend executor — reads through these rather than constructing its own calls.
/earn and /recompute turn trading into an on-chain reputation commitThe engine speaks HTTP over Hono on port 8799. Every route is thin: it reads the chain or the scorer, commits when needed, and returns JSON the dashboard renders. The frontend never talks to the chain for reads — it talks to this API.
| Method & path | What it returns / does |
|---|---|
GET /health | {ok, chainId, signer, dataSource, explainer, byreal} |
GET /deployment | All deployed contract addresses |
GET /agents | Leaderboard (cached registry scan of ids 1–8) |
GET /agents/:id | Reputation, credit line, and account USDC balance |
GET /agents/:id/track | Trading record: venue, recent trades, PnL/sharpe/drawdown/equity |
POST /agents/:id/recompute | Recompute from current data → commit → explanation |
POST /agents/:id/earn | Simulate winning trades → recompute → commit (the live demo beat) |
GET /events | Recent on-chain events (ring buffer) |
GET /vault | Vault stats: assets, borrowed, available, utilization |
POST /preview-spend | Dry-run a spend → reason code + label |
POST /spend | Execute a real guarded spend (Chapter 10) |
GET /x402/:service · POST /x402/:service/buy | The HTTP-402 round-trip (Chapter 11) |
GET /byreal/market | Live Byreal CLMM context (Chapter 13) |
GET /onchain · POST /onchain/commit/:id | Verified Mantle-mainnet PnL record & commit (Chapter 13) |
POST /onboard | Attest a starter reputation for a wallet’s derived agent (Chapter 14) |
The /earn endpoint is the demo’s pulse: it appends a handful of winning trades to the
agent’s history, recomputes the score, commits it to the oracle as a real transaction, and returns
the before/after scores plus a GLM explanation. The dashboard’s “Run a trading session”
button is a single call to it.
Two loops keep reads instant. startIndexer() scans the oracle, manager, and hook for new
events every 12 seconds (backfilling 2,000 blocks on first run) and pushes them into a 200-entry ring
buffer, so /events is an in-memory read. startRegistry() sequentially scans
agent ids 1–8 every 20 seconds and caches those with a score or an open line, so the
/agents leaderboard never blocks on the chain.
spend.ts submits a real spend through the GuardedAccountThe signature beat from Chapter 5, made live. spend.ts exposes
executeSpend({accountAddr, to, amount, ensureFunds}), which submits a real
GuardedAccount.execute() to Mantle Sepolia and returns the mined outcome.
// spend.ts — the explicit gas limit is the whole trick
const hash = await walletClient.writeContract({
...guardedAccountAt(accountAddr),
functionName: "execute",
args: [to, value, data],
gas: 300_000n, // skip eth_estimateGas, which would throw on a guarded revert
});
const rcpt = await publicClient.waitForTransactionReceipt({ hash });
return { txHash: hash, status: rcpt.status, to, amount, reason }; // status: "success" | "reverted"
Without the explicit gas, viem estimates gas first, the guard’s revert throws, and
the transaction never broadcasts — the rejection would be a client-side exception, not an on-chain
fact. With it, the transaction mines as status 0, carrying the guard’s typed error
in the receipt. An allowlisted, in-bounds spend mines as status 1. previewSpend returns the
reason code first so the UI can label the verdict, and ensureFunds mints test USDC into the
account when it is low so the demo never stalls on a dry balance.
A judge can open the reverted transaction on Mantlescan and see status 0,
Destination not allowlisted, and an attacker balance of zero. The funds never moved, and
the proof is a public, immutable receipt — not a screenshot of an app saying “blocked”.
This is the difference the whole project is built to demonstrate.
402 + accepts → X-PAYMENT → 200 + X-PAYMENT-RESPONSEAgents do not only borrow — they earn. x402.ts implements a faithful
HTTP-402 flow so one agent can buy a service (here, agent #1’s momentum signal) from another, with
real settlement on Mantle. The ERC-8004 spec explicitly points at x402 for payment-enriched feedback;
the swing closes that loop.
X-PAYMENT header, and the
server verifies the Transfer log before delivering the signal.The verifier is strict. It decodes the base64 X-PAYMENT token to a tx hash, fetches the
receipt, and confirms a Transfer log to payTo of at least the required amount.
Replays are blocked by a consumed set keyed on the lowercased tx hash — a payment can
redeem a service exactly once. On success, the engine calls recordPaidJob on the provider,
recomputes its score, commits it, and returns an X-PAYMENT-RESPONSE header. The
POST /x402/:service/buy endpoint runs the whole round-trip autonomously for the dashboard,
returning the quote, the settlement tx, the delivered payload, and the reputation delta.
Z.ai is both a sponsor and a judge, so using GLM as the reasoning layer is strategically
smart and technically sound. explain.ts narrates each reputation/credit move —
“Reputation 500 → 750/1000 (T2 Established). Promoted. Drivers: win-rate 75%, drawdown-health
95%, consistency 88%, validation 94%. Sybil penalty 10%.”
// glm.ts — fast, and never blocks a request
const res = await fetch(`${ZAI_BASE_URL}/chat/completions`, {
method: "POST",
headers: { Authorization: `Bearer ${ZAI_API_KEY}`, "content-type": "application/json" },
body: JSON.stringify({
model: ZAI_MODEL, // glm-4.5-flash (free tier)
temperature: 0.4, max_tokens: 256,
thinking: { type: "disabled" }, // reasoning off → ~3s replies, not 15s
messages: [{ role: "system", content: SYSTEM_PROMPT }, { role: "user", content: factSheet }],
}),
signal: AbortSignal.timeout(15_000), // a slow provider never stalls the request
});
Two settings are load-bearing. thinking: { type: "disabled" } turns off the
reasoning-model chain so replies come back in about three seconds instead of timing out; the free
glm-4.5-flash tier is used because glm-4.6 needs a paid balance. The 15-second
abort means a slow or unreachable provider degrades gracefully — explain() falls back
to a deterministic template and logs the fallback, and /health reports
explainer: glm only when the key is live.
An early fact sheet handed GLM a credit-limit figure, and the model cited “$37,945” in its prose while the credit panel rendered “$35,777” from a live contract read. Two sources of truth for one number is one too many. The fix: the fact sheet now carries only score, tier, stance, and drivers — no dollar figures. Dollar amounts come exclusively from on-chain contract reads, so the narration can never contradict the chain.
byreal.ts shells out to the official @byreal-io/byreal-cli for real
overview data (TVL, 24h volume, fees, pool count), caches it for 60 seconds, and degrades
gracefully if the CLI is absent. The framing is honest: Byreal’s CLMM is on Solana, so this is live
signal context the agent reads — reputation, credit, and safety stay on Mantle. The
dashboard labels it as such.
The honest answer to “is this real on-chain activity?” lives in
data/mantleOnchain.ts. It reads a real trader’s ERC-20 transfers on Mantle
mainnet (chain 5000) via the Etherscan V2 account API, groups them by transaction into
swaps, and reconstructs realized PnL in USD — using stablecoin legs as the numéraire, with no
price oracle.
// data/mantleOnchain.ts — stablecoins are the numéraire
const STABLES = new Set(["USDC","USDT","USDT0","USDe","USDY","mUSD","axlUSDC"]);
// per clean 1-in / 1-out swap:
// stable OUT, token IN → BUY: add to position (qty, avg cost)
// stable IN, token OUT → SELL: realize PnL = stableIn − avgCost*qty, close position
// multi-hop / LP transfers are skipped for clean reconstruction.
The result is a full record: realized PnL, win rate, closed trades, open positions, a span in days, and
every trade as a clickable mainnet transaction. Those reconstructed trades become
ScoreInputs the same scorer consumes, so the engine produces a real, derived score
(the discovered trader at 0x4993…0814 scores 710, T2 — 84% win, +$768 realized). The
POST /onchain/commit/:id route commits that derived score to the Sepolia oracle for an
isolated agent slot — live swaps → realized PnL → reputation → on-chain commit, end to
end, with nothing simulated.
The benchmark wallet is found on-chain, not curated. A helper
(pnpm --filter @swing/engine scan --router <dex-router>) pulls recent traders from a
DEX router and ranks them by the engine’s own reputation score — so the wallet on
screen is genuinely one the engine rates highly (here, 710/T2), not a high-volume break-even bot. It
stays a labelled reference benchmark on the /agents page, not the holdings of the
selected agent, because clean PnL reconstruction needs stablecoin-legged round-trips, which most
addresses lack. Point it at a RealClaw agent by dropping its address into
MANTLE_TRADER_ADDRESS.
POST /onboard is the bridge between “a person with a wallet” and
“an agent with credit.” The engine attests a starter reputation for the wallet’s derived
agent — the user cannot do this themselves, because only the engine signer may write the oracle
(Chapter 3).
// api.ts — deterministic, collision-free for 900k addresses
const owner = getAddress(body.owner.toLowerCase()); // checksum-normalize at the boundary
const agentId = 100_000n + (BigInt(owner) % 900_000n); // derive a 6-digit id from the wallet
await commitRaw(agentId, 700, evidenceHashFor(owner)); // starter score 700 → tier T2
return { agentId, owner, score: 700, tier: 2, tierName: "Established", evidenceHash, txHash };
The starter score of 700 lands the new agent in T2 — enough to unlock a meaningful,
partially-uncollateralized line so the demo has something to draw against. The id is derived from the
owner address (100_000 + owner % 900_000), so it is deterministic and unique per wallet, but
it sits far outside the 1–8 range the registry scanner enumerates — which is why the frontend
remembers launched agents in local storage (Chapter 18).
This one endpoint is what turns the operator-launch stepper into a genuinely user-owned flow: the engine attests reputation (oracle-signed), and then the user’s own wallet signs everything else — create account, open line, draw. Reputation → credit → spend, owned end-to-end by the person at the keyboard.
The dashboard is Next.js 15 with Tailwind v4, and its look is deliberate: a near-black, zero-saturation palette where the only colour is red, reserved exclusively for danger — the rogue, the blocked, the reverted. Every other meaning is carried by brightness. A judge should feel, before reading a word, that this is a serious instrument, not a toy.
Colours are defined as oklch CSS variables in globals.css. The achromatic ramp runs from
paper (near-black background) to ink (near-white text); red is the single chromatic exception.
| Token | Value | Role |
|---|---|---|
--color-paper | oklch(0.145 0 0) | page background |
--color-card | oklch(0.178 0 0) | panel background |
--color-ink | oklch(0.985 0 0) | primary text (near-white) |
--color-muted / --color-faint | oklch(0.78 / 0.6) | secondary / tertiary text |
--color-line / line-strong | oklch(0.27 / 0.37) | hairline borders / focus |
--color-danger | oklch(0.71 0.19 23) | the only hue — rogue / blocked |
--color-t0 … t3 | oklch 0.6 → 0.985 | trust tiers, dim → pure white |
The tier ramp is the cleverest part. A reputation score is encoded as brightness: T0 is a dim grey, T3 is pure white. A glance at a leaderboard reads as a gradient of trust without a single coloured pixel — which keeps red meaningful. When the eye finds red, something is wrong, every time.
Typography is Geist Sans for display and body, Geist Mono for addresses, hashes, and amounts (with
tabular-nums so trailing zeros line up). Cards share a 12px radius, a 1px line border, and a
subtle inset highlight over a deep outer shadow — a quiet, instrument-panel feel. Behind everything,
StarField.tsx paints 120 twinkling, slowly-falling stars onto a canvas at z-index −10,
ported from the Entrypoint Labs site — cosmic, distant-future, never distracting.
The dashboard is served with next start (production), reading the engine on
:8799. That means every UI change needs a pnpm build and a restart to appear
— a deliberate choice for a stable demo surface, and a thing to remember when iterating.
Five routes, each a distinct job in the story: convince, operate, rank, lend, launch.
| Route | Purpose |
|---|---|
/ landing | The thesis in one screen: hero line (“Agents earn trust. Capital follows. Rogue spends revert.”), live stats from /vault and /agents, a five-step “how it works”, and the five-piece architecture overview. |
/console hub | The operational dashboard for one agent — reputation gauge, credit panel, trading record, activity feed, x402, and the spending-control simulator. Polls the engine every 7s. |
/agents registry | A brightness-ranked leaderboard of scored agents, the connected wallet’s “Your agents” section, and the labelled real-world on-chain benchmark trader. Refreshes every 8s. |
/vault lend | The ERC-4626 pool: TVL, utilization, and the user-signed lender flow (faucet → approve → deposit → withdraw). |
/launch operator | The four-step stepper that turns a wallet into a credit-backed agent — engine attests, then the wallet signs create → open line → draw. |
The landing page is unapologetically a pitch: it leads with the rogue-reject and the earn→credit
loop in the first two beats, exactly as the rubric rewards. The console is where a judge watches the loop
happen for a single agent; /vault and /launch are where they can participate
with their own wallet rather than watch a server-signed puppet.
Console.tsx is the composition. In order, it stacks: the agent header and
reputation gauge, the credit panel, a live Byreal market strip, the trading record, the activity feed, the
x402 panel, and the spending-control simulator. It fetches agent state, track, and events every 7 seconds;
the “Run a trading session” button fires api.earn(), commits on-chain, and animates
the score moving.
useSpring, and a tier badge.Two more carry the brand: StarField.tsx (the canvas backdrop) and ui.tsx
(the shared primitives — Card, Label, Dot,
ExplorerLink, Copyable). tiers.ts centralizes the tier styling
(TIER_STYLE and tierHex) so brightness is defined once. The discipline across all
of them is the same: no mocks — every verdict on screen is a mined Mantle Sepolia
transaction with a status code and a link.
/vault — user-signed ERC-4626/launchThe guided demo proves the loop without the viewer owning anything. These flows let a judge use their own wallet — lend real test capital, or launch their own credit-backed agent and sign every step.
lib/wagmi.ts is a single-chain wagmi v2 config: mantleSepoliaTestnet, the
injected() connector (MetaMask / Rabby / Brave), and an HTTP transport to
rpc.sepolia.mantle.xyz. Providers.tsx wraps the app in
WagmiProvider + QueryClientProvider. (wagmi v3 broke on a barrel-import bug, so
the stack is pinned to the stable v2 line.)
Network.tsx centralizes the “are we on Mantle?” question. useMantleGuard()
exposes { wrongNetwork, switchToMantle }; useAutoSwitchMantle() — mounted once
in the header — auto-prompts the switch (and the add-chain) the moment a connected wallet is on the
wrong chain. WrongNetworkBanner renders above any signing area, and every signing button
(Draw, Deposit, Create…) is disabled while off-chain, so a
transaction can never fire on the wrong network and throw a cryptic error.
LenderPanel.tsx on /vault: connect → faucet-mint test
USDC → approve → deposit → hold scUSDC →
withdraw. Standard ERC-4626, fully user-signed, reading balances through
useReadContracts against the minimal ABIs in lib/contracts.ts.
OperatorLaunch.tsx on /launch closes the whole loop with the user’s
wallet:
/onboard (you can’t
self-assign reputation); steps 2–4 are signed by the user’s own wallet. The stepper reads the
predicted account via factory.predict and borrowing power via
manager.borrowingPower before each signature. Verified on-chain end to end via
cast: $5,000 borrowing power at T2, $500 landed in the account.Because the launched agent’s id is derived (100_000 + owner % 900_000) and sits
outside the registry’s 1–8 scan, MyAgents.tsx remembers it: lib/myAgents.ts
stores the id in localStorage keyed by wallet, and the component reads each agent’s live
state back from chain by id — so a refresh never loses an agent you launched. It appears on
both /launch and /agents, linking through to its console.
The frontend never re-implements protocol math. It calls factory.predict for the account
address, manager.borrowingPower for the limit, and the shared tier-math mirror for display
— so what the UI shows before a signature is exactly what the contract enforces after it. Honest
by construction.
A monorepo in three workspaces: the contracts (the truth), the engine (the projection), and the web app (the surface), with a shared package binding them.
contracts/ # Foundry — the spine, the source of truth
src/
ReputationOracle.sol R + tier per agentId, engine-signer-gated
CreditVault.sol ERC-4626 lender pool + inflation mitigation
CreditManager.sol tier → limit, open/draw/repay/liquidate, 2-slope interest
libraries/
TierMath.sol pure: tier(R), limit(R), caps, collateral factors
SpendingGuardLib.sol the six-check ladder, shared by both account paths
SpendDecodeLib.sol resolves effective dest + amount of a call
accounts/
GuardedAccount.sol minimal direct wallet (bundler-independent)
GuardedAccountFactory.sol CREATE2 deploy + predict()
modules/
SpendingGuardValidator.sol ERC-7579 Type 1 — scoped agent session key
SpendingGuardHook.sol ERC-7579 Type 4 — runs the ladder in preCheck
script/Deploy.s.sol deploys all 7 + seeds the vault
deployments/5003.json canonical Mantle Sepolia addresses
packages/shared/ # TS mirror: tier math, ABIs, config, types
apps/engine/src/ # Node/TS reputation engine (Hono + viem)
scoring.ts the formula + evidence hash
oracle.ts / chain.ts commit bridge + viem client (batch:false)
api.ts the HTTP surface (Chapter 9)
spend.ts live guarded spend (explicit gas → status 0)
x402.ts / glm.ts / explain.ts payments + GLM narration
byreal.ts live Byreal CLMM context (Solana, read-only)
data/mantleOnchain.ts real Mantle PnL reconstruction
registry.ts / indexer.ts leaderboard scan + event ring buffer
apps/web/ # Next.js 15 dashboard
app/ (page.tsx, console, agents, vault, launch)
components/ (Console, ReputationGauge, CreditPanel, TrackRecord,
SpendingControls, X402Panel, OnchainPanel, LenderPanel,
OperatorLaunch, MyAgents, Network, ConnectButton, StarField, ui)
lib/ (api, config, wagmi, contracts, format, myAgents)
Live on Mantle Sepolia (chainId 5003). Deployer / oracle signer:
0x4D6A7d6bF3C0a885D581AacEC0345526bd33273E. Source-verified on Mantlescan.
| Contract | Address |
|---|---|
| MockUSDC | 0xa29799A188C220B17788a355Ec0166523172B09d |
| ReputationOracle | 0xe00962601106D055be7A1f97CD53c9C7B4b46632 |
| CreditVault (ERC-4626) | 0x0aD20c99D72AA4371317a85A85Ce39C318a2b114 |
| CreditManager | 0x1be497f127561a8F3e53aF53452Ce6cdC09e31a8 |
| GuardedAccountFactory | 0xB1ccd35E453eB0a4eeD05a3AE0BFC638B397B997 |
| SpendingGuardHook (7579 T4) | 0x9A0735F793e438b63241252EB54ef7B519E698Bb |
| SpendingGuardValidator (7579 T1) | 0x60C3C40566a932bAcA3AfD23699C38e9F0F3E2C3 |
| Demo GuardedAccount (agent 1) | 0xA6f857F91C57f6DaC7BAf5F4A2abfA026A729365 |
| Step | Result |
|---|---|
| Reputation commit | R = 832 → tier T3 |
| Credit line | borrowing power 37,945 USDC = creditLimit(832) |
| Draw | 5,000 USDC disbursed to the guarded account |
| Allowed spend | 1,000 USDC → allowlisted merchant ✓ status 1 |
| Rogue spend | → non-allowlisted attacker ✗ REVERTED, status 0 |
MANTLE_SEPOLIA_RPC_URL, ENGINE_PORT=8799, DATA_SOURCE=simulatedORACLE_SIGNER_PRIVATE_KEY — the engine’s commit key (throwaway testnet key only)ZAI_API_KEY, ZAI_MODEL=glm-4.5-flash — GLM narration (optional)MANTLESCAN_API_KEY, MANTLE_TRADER_ADDRESS — on-chain PnL record (optional)NEXT_PUBLIC_ENGINE_URL, NEXT_PUBLIC_EXPLORER — the web app’s pointersThe real .env (private key) stays gitignored; only placeholders live in
.env.example. Run the stack with
ENGINE_PORT=8799 pnpm --filter @swing/engine start +
pnpm --filter @swing/web start (web on :3000 reads engine on :8799).
The whole loop, told in ninety seconds. The climax is beats 6–7: the drain that reverts on-chain.
| # | On screen | Beat |
|---|---|---|
| 0 | Landing | “Agents earn trust, capital follows, rogue spends revert. The whole loop on Mantle.” |
| 1 | Console, agent #1 | ERC-8004 identity + a track record; the engine scores it and commits on-chain. |
| 2 | Run a trading session | It trades; the engine recomputes and commits the update — a real oracle tx. |
| 3 | Credit panel | Score → tier → an uncollateralized limit funded by the ERC-4626 vault. |
| 4 | x402 panel | Another agent buys this one’s signal over x402; proof-of-payment loops back as reputation. |
| 5 | Spending controls | “Pay merchant” — an allowlisted, in-bounds spend executes (status 1). |
| 6 | Spending controls | “Drain to attacker” — the card shakes red. |
| 7 | Mantlescan | Click the tx → status 0 (failed), the guard’s typed error, funds never moved. |
| 8 | Console | “Identity, reputation, credit, payments, safety — every step a verifiable Mantle event.” |
Beyond the guided demo, the presenter can connect their own wallet and either lend on
/vault or launch their own credit-backed agent on /launch — turning the
pitch from “watch this work” into “do it yourself.”
| Term | Definition |
|---|---|
| ERC-8004 | The “trustless agents” standard: on-chain registries for agent identity, reputation, and validation. The swing keys reputation by ERC-8004 agentId. |
| ERC-4626 | The tokenized-vault standard. CreditVault is one; lenders deposit and earn yield as borrowers repay interest. |
| ERC-7579 | Modular smart-account standard with typed modules. The swing ships a Type-1 validator and a Type-4 hook for the guard. |
| x402 | The HTTP-402 agent payment rail: a server returns 402 with terms, the client pays on-chain and retries with proof. |
| Tier (T0–T3) | Trust bands derived from reputation R, setting credit limit, caps, and collateral. T0 unproven → T3 trusted. |
| Evidence hash | A keccak fingerprint of the exact scoring inputs, committed on-chain so a score can be independently recomputed. |
| Rogue-reject | The signature beat: a policy-breaching spend that mines as a status-0 reverted transaction on Mantle. |
| The numeraire | Stablecoins (USDC/USDT/…) used as the unit of account to reconstruct realized PnL from DEX swaps without a price oracle. |
| GuardedAccount | The minimal, bundler-independent smart wallet that runs the spending guard directly — the guaranteed demo path. |
| scUSDC | The ERC-4626 share token a lender receives for depositing USDC into the CreditVault. |
earn → reputation → credit → deploy → rogue-reject — every step a verifiable Mantle event. That is the swing.