Threat model
Sections A, B and C of Adag's threat model, copied from docs/security/threat-model.md in the repository.
Written at the architecture gate on 25 September 2026, before any contract code, and kept current as the code lands. Method: a pre-implementation design review, run from a plain functional description of the system, with security deliberately left out of that description so the review had to reason from scratch. Section C is the definition of done: the implementation is complete only when every invariant in it is shown to hold, each with the file and function that upholds it and a test that attacks it.
What Adag is, in one paragraph: a web app and smart contracts on Arc mainnet. A person holding cirBTC pays a bill in USDC or EURC in one signed transaction. That transaction pledges cirBTC on Morpho Blue, borrows the bill amount, and pays the person who issued the bill, with the bill's reference attached through Arc's Memo contract. The transaction is refused if the loan would pass 40% of the bitcoin's value (Morpho liquidates at 86%). Each bill can be paid once. Bills can also be paid from an existing USDC or EURC balance.
A) App-class risk profile
What kind of system this is
In security terms, Adag is three systems sharing one name, plus an optional fourth.
- A blind transaction builder. A web page assembles a batch of calls, and a person's wallet signs the batch once. The wallet shows a hex blob for Multicall3From, so the payer cannot check what they sign. Whatever the page builds, the payer's money does.
- A public, permissionless record book. Strangers write records (bills), other strangers act on them (pay), and anyone reads them. There is no login. The record is the only thing that connects the person who ships goods to the person who pays.
- A renderer of untrusted content to people about to make a payment decision. Bill links, reference text, addresses and amounts arrive from URLs, from chain storage and from event logs. The page shows them to the payer as "this is who you are paying and how much".
- Optionally, a small custodial service: a hot wallet that spends its own USDC on fees for strangers.
Vulnerability categories that bite this class, tied to Adag's data flows
- Calldata construction and blind signing. This applies, and it is the largest risk. When paying from bitcoin, the payer signs one Multicall3From transaction carrying approve, supplyCollateral, borrow, a Memo-wrapped payment and the Adag step. A bug in how the app picks a target address, a receiver, an approval amount or an allowFailure flag is a direct loss of the payer's cirBTC or stablecoins. A compromised page (a dependency, the hosting, DNS) has the same power. The wallet cannot save the payer here.
- Replay and double-pay of records. This applies. A bill id must mean one bill and one payment. Under the link model, a signed bill can be presented twice: by two customers, on another contract, or after a redeploy. Under both models, a batch can list the same id twice. A second customer paying an already-paid bill loses money unless the contract refuses.
- Forged or ambiguous authorship. This applies. The create form asks the payee for "their own address". If the contract accepts a payee address from the form instead of taking the sender, anyone can write bills that name a victim as payee, filling the victim's dashboard with bills they never issued. Under the link model, the payee is whoever the signature recovers to, so several things become authorship bugs: a zero-address recovery, a malleable signature, and a contract-wallet payee that cannot sign.
- Phishing through shareable links. This applies. A bill link carries a payee address and a reference that says "Rent, March". Nothing in Adag can say whether that address belongs to the landlord. This is partly a non-goal, but the rendering rules below shrink it: the page shows exactly what will be paid, to exactly which address, from the same bytes the transaction uses.
- Content injection. This applies. The reference is 140 free bytes written by a stranger. It is rendered on the bill page and both dashboards, and copied into the Memo event's data, where explorers and indexers pick it up. HTML, script, right-to-left overrides, unicode look-alikes of addresses, and invalid UTF-8 are all valid 140-byte strings.
- Parameter injection through the URL. This applies under the link model, where the URL is the bill. Any query parameter the app turns into a contract address, token address, chain id or receiver is a drain path: the attacker's link makes the payer approve the attacker's contract.
- Numeric and oracle errors in the loan check. This applies. The Adag step reads the payer's Morpho position (in shares), the market totals and the oracle price, then compares debt to 40% of collateral value. There are four places to be off by a power of ten: share-to-asset rounding, interest not yet accrued, cirBTC's 8 decimals against the stablecoins' 6, and the oracle's scale factor. Wrong in one direction, every payment reverts. Wrong in the other, the payer borrows past the line the product promised.
- Trust in sibling calls. This applies. The Adag step is the last call in a batch the payer composed, and the payer chooses which calls run and in what order. A design where Adag remembers a balance from an earlier call and compares it later can be spoofed. The payer runs the earlier call in a different transaction, waits for the payee's balance to rise for another reason, then runs the later call.
- Fake proof of payment in logs. This applies. The Memo contract is public, and anyone can emit a Memo event carrying any bill id. A dashboard that counts Memo events with a given bill id as payments is counting attacker output.
- Untrusted upstream data. This applies. The public RPC, Morpho's GraphQL API and any indexer feed the UI and the pledge-size calculation. A wrong or malicious answer produces one of two failures. A wrong pledge size is caught by the on-chain check, so the payment reverts. A wrong paid or unpaid display is worse: the payer may pay twice, and only the contract's refusal saves them.
- Hot wallet drain and fee griefing. This applies only if the fee sponsor ships. Attackers send the sponsor transactions that revert, or thousands of tiny valid ones, and the sponsor pays the fee for each. A replayed or over-broad signed authorization lets the sponsor, or someone who steals its key, move the payer's tokens somewhere other than the bill.
- Admin key compromise. This applies only under the owner option. An owner who can change the market ids or the 40% limit can point Adag at a market with a friendly oracle or a fake loan token. Payments would then "succeed" in a worthless currency.
- Reentrancy. The risk is low. Adag holds no funds and the tokens have no transfer hooks. It still applies as discipline: the paid flag is set before any external call, so nobody can observe "unpaid" after money has moved.
- Issuer controls on the tokens (blocklist, pause). This applies as a fail-closed case, not a loss. A blocklisted payer or payee makes the transfer revert, and the whole batch undoes itself. The UI must explain the revert, and no one may be able to mark a bill paid when the transfer reverted.
Categories that do not apply, and why
- Server-side request forgery: the app fetches fixed endpoints (the Arc RPC and Morpho's GraphQL), never a URL a user supplies.
- SQL or query injection: there is no database and no user-driven query. An indexer, if built, only appends from chain logs.
- Authentication and session bugs: there are no accounts, passwords or sessions. The wallet signature is the only authority. (For the components added on 26 September this no longer holds: alert links are written by signed messages, C46 and C47.)
- Classic CSRF: no server holds state that a forged request could change. (For the components added on 26 September this no longer holds: the alert store is server state, C47.)
- Multi-tenant data isolation: every bill is public on chain by design, so there is no private data to leak between users. Privacy is a named non-goal. (For the components added on 26 September this no longer holds: the wallet-to-chat table is private, C49.)
- File upload and path traversal: there are no files.
B) Threat model
Trust boundaries
- URL to page. Every character after the domain is attacker-written.
- Page to wallet. The page hands the wallet calldata to sign. The wallet trusts the page because the payer clicked; the payer cannot read the calldata.
- Payee's form to the bill record. Under on-chain storage, the boundary is the create transaction. Under the link model, it is the typed-data signature, and the record stays off-chain and unverified until the pay transaction.
- Chain to page. RPC responses, event logs and Morpho's GraphQL are inputs the page renders and computes with. The RPC is a third-party server.
- Payer's batch to Adag. Through Arc's CallFrom, the sender Adag sees is the payer's wallet. Every argument, and every earlier call in the batch, is chosen by the payer.
- Adag to Morpho, the oracle and the token contracts. Their return values are Adag's inputs. Adag trusts them to be honest (a non-goal) but not to be well-formed: a zero price or a reverting read is still an input.
- Adag to its own events, then to indexers, explorers and dashboards. Everything emitted becomes someone else's input.
- Internet to the sponsor service, to the hot wallet, to the chain. The sponsor is the only place a private key sits on a server.
- Build pipeline and hosting to the JavaScript served. Dependencies, Vercel and DNS.
- The wallet's current chain to the page's assumption of chain 5042.
Attacker-controlled inputs
Direct:
- Bill fields: payee address, currency, amount, due date, reference bytes, and the salt or nonce. Under the link model, also the signature bytes and the full struct.
- Bill link parameters: their length and encoding, and any parameter the app was not expecting.
- The bill id passed to the pay step, and the batch of bills (its length, order and duplicates).
- Every argument of every call in the batch: amounts, receiver and onBehalf addresses, market params passed to Morpho, the allowFailure flag on each call, and the target address of each call.
- The sender's identity: any wallet. That includes the payee's own wallet paying its own bill, and a wallet that already holds Morpho positions Adag did not create.
- Sponsor API bodies: transactions to sponsor, signed authorizations, EIP-7702 delegation signatures, and claimed sender addresses.
- The wallet's chain id and selected account at the moment of signing.
Indirect:
- The oracle price at execution time. It can differ from what the page computed with, and it can be zero, huge, or revert.
- The payer's Morpho position and the market totals. The payer can change them in the same transaction before the Adag step: supply, repay, borrow more, withdraw.
- Token balances of the payee, which other senders can change in the same block.
- Stored data read back later: bill data (reference, payee, amount, currency) stored earlier, and any stored snapshot read in a later call.
- Memo events from any sender, with any bill id and any data.
- Third-party answers: RPC responses (malicious or merely broken), GraphQL responses, indexer output, and swap quotes from App Kit or Uniswap.
- Token contract state: the blocklist, pause, and any upgrade the issuer performs.
- The payer's account code under an EIP-7702 delegation.
- The block timestamp, if the due date is ever compared to it.
- Recovered signer addresses. They depend on attacker-supplied signature bytes and can be address zero.
Privileged position and assets
- The Adag contract. It holds no tokens, but it holds two valuable things: a momentary token allowance from the payer during the pay call, and the paid flag, which is the truth a payee ships goods against.
- The web page. It writes what the wallet signs. If the page is wrong or compromised, it can spend everything in the payer's wallet, because the payer signs a blob.
- The sponsor. It holds a private key and a USDC balance, and it can submit transactions that carry other people's signed authorizations.
- The operator. It holds:
- the deploy key, used once and then irrelevant if there is no admin;
- the Vercel account and DNS;
- the environment variables;
- under the owner option, a live key that can repoint the contract.
- The indexer. It holds what the dashboards show, and it can lie by omission.
- The loan check. Adag does not hold the position, but its arithmetic decides how close the payer is pushed toward Morpho's 86% liquidation line.
Attacker goals, highest value first
- Drain the payer through the transaction builder. Enabled by the pay, batch, repay and sponsor flows:
- a URL parameter that becomes a target or token address;
- an approval to the wrong spender, or for more than the amount;
- a receiver that is neither the payer nor the payee;
- an EIP-7702 delegation to attacker code;
- a compromised page.
- Get a bill marked paid without paying. Enabled by any of:
- the Adag step trusting sibling calls, for example a stale snapshot;
- allowFailure set on the transfer;
- a zero-amount bill;
- a bill whose currency is a worthless token;
- a rounding path where the amount moved is less than the bill;
- a dashboard that treats Memo events as payment.
- Get paid twice, or get someone else's payment. Enabled by any of:
- replaying a signed bill;
- an id collision;
- an edited link whose displayed payee differs from the signed payee;
- a redeploy that reuses ids;
- a batch listing one bill twice.
- Phish through a link. A bill names the attacker as payee, and either carries a reference that impersonates a merchant or one that renders as HTML or as a fake address.
- Push the payer past 40%, or block every payment. Enabled by errors in the loan check: the oracle scale, share rounding, interest not yet accrued, a decimals mismatch, or checking the wrong market.
- Drain the sponsor. Enabled by reverting transactions, sheer volume, replayed authorizations, or theft of its key.
- Under the owner option, steal the owner key and repoint the markets, the tokens or the limit.
C) Defensive-programming standards (definition of done)
Each invariant is an outcome, not a step. It is cited by number beside the file and function that upholds it, and a test shows it holding under attack. Where the design had an open choice, a "Choice" line says which option makes the invariant easier or harder.
Money movement
C1. A bill is marked paid only when the payee's balance of the bill's currency rose by at least the bill amount within the same call that marks it paid. Both readings come from the token contract, one before the transfer and one after. No reading stored by another call or another transaction counts. A payer who is also the payee produces a zero rise and is refused. Choice: easy if the loan goes to the payer and Adag then moves the amount, since Adag does the transfer and measures it in one function. Hard if the loan goes straight to the payee, which needs a snapshot from an earlier call. That is only safe in transient storage that dies with the transaction; a stored snapshot can be spoofed.
C2. The only token movement Adag can cause is the exact bill amount, from the sender, to that bill's payee, in that bill's currency, within the call that marks that bill paid. Adag never holds tokens between calls, has no function that moves tokens for any other reason, and never needs an allowance larger than the amount in flight. Choice: trivially true if the loan goes straight to the payee, since Adag moves nothing. If the loan goes to the payer, it is the scope of Adag's single transfer.
C3. Every call in a batch the app builds follows these rules. No address, chain id, selector or amount in any call is taken from the URL or from any upstream response.
- It targets one of the addresses fixed at build time: Adag, Morpho, Memo, Multicall3From, or the three tokens.
- Every approval goes to Morpho or Adag only, for exactly the amount that batch uses.
- Every allowFailure is false.
- Every onBehalf and receiver is the payer. The one exception is a borrow receiver that is the payee read from the verified bill.
- From a link, the app reads exactly one thing: a bill id, or a signed bill and its signature.
Choice: on-chain bills make the link a single id, the smallest surface. Signed links carry the whole bill, so the bill must be verified before any field is used.
Scope (added at the backend review): C3 and C4 bind every script that signs, including prove-it.mjs and deploy.sh, not only the web app. Market params placed in a signed call are proven by hashing them to the fixed market id, never trusted from an RPC answer.
C4. The app builds no transaction and requests no signature unless the wallet reports chain id 5042. Every typed-data domain names chain 5042 and the deployed Adag address. A bill signed for another chain or another Adag deployment cannot be paid here.
Bill identity and replay
C5. A bill id is computed from the full bill content, the payee, a fresh nonce or salt, chain id 5042 and the Adag address, by one encoder that the contract owns. The UI shows only ids it recomputed from content with that same encoding, never an id it received in a link. Choice: with on-chain bills the contract assigns the id and the UI only reads it, which is easiest. With signed links, the id is the typed-data hash, and the UI's encoder must match the contract's byte for byte.
C6. A bill is marked paid at most once, through any entry point: single pay, batch pay, or the sponsor path.
- The paid flag is written before any external call.
- A batch that lists the same id twice reverts as a whole.
- A paid signed bill cannot be paid again: not with the same signature, a differently encoded signature, or a re-signed copy of the same content.
Choice: the same under both storage models: one set of paid ids on chain.
C7. A bill's payee is the account that wrote it: the sender for an on-chain create, the strictly recovered signer for a signed bill. The payment is refused if the recovery yields address zero, the signature has a high s value, the signature is not exactly 65 bytes, or the bill's typed-data domain differs from Adag's. Recovery uses the standard library's strict recovery, never raw ecrecover. Choice: with on-chain bills, "payee is the sender" is one line. With signed links, it needs strict signature checks, and a contract wallet cannot be a payee unless contract-signature support (EIP-1271) is added on purpose.
C8. No payable bill can exist with any of these:
- an amount of zero;
- a currency that is not the loan token of one of the two fixed Adag markets;
- a payee of address zero;
- a reference longer than 140 bytes.
The contract enforces this at the moment it matters: at creation for on-chain bills, at payment for signed links. The form's own validation is a courtesy, not the guard.
C9. Only a bill's payee can void an unpaid bill, and a voided bill can never be paid. Without a void, a mistaken bill stays payable forever. Choice: with on-chain bills, a state flag. With signed links, an on-chain set of voided ids; otherwise a signed bill can never be recalled.
Loan safety
C10. After the payment step, the payer's debt is at or under 40% of their collateral value, in the market whose loan token is the bill's currency.
- Debt is borrow shares converted to assets, rounding up, after interest is accrued in the same transaction.
- Collateral value is collateral times the oracle price divided by the oracle scale, rounding down.
- The comparison is done in integers with no intermediate overflow.
- The check reads live Morpho and oracle state. Nothing the caller passes in can substitute for it.
Choice: with no admin, the 40% and the market ids are constants, which is easiest. With an owner, they are storage, and every change must emit an event and take effect only after a delay. Decided at the gate (25 September): the new-debt rule. On every payment, for both Adag markets, Adag compares the payer's live position with the last one it recorded for that payer and market.
Amended at the pre-deploy code review (25 September). The first version compared borrow shares alone. A separate review showed it could be skipped: pay once, close the loan outside Adag, then re-open it with exactly the recorded share count against far less collateral. The rule now records both borrow shares and pledged collateral:
- The check runs whenever debt is above zero and the position is not at least as safe as the recorded one, meaning shares went up or collateral went down.
- A position with no more shares and no less collateral than one Adag already accepted skips the check. It can only be worse than that position through price or interest drift, which is exempt by design.
So the check runs whenever the loan has become riskier by the payer's own action, and no argument can skip it.
Why this holds: Adag only overwrites a recorded position without a check when the new one has no debt, or is dominated by the old one (no more shares, no less collateral). Every unchecked position with debt is therefore dominated by the last position a check accepted. Invariant I7 asserts this over random action sequences that include the bypass attempt; with the old shares-only rule restored, I7 fails within 5 calls. One cost is named: a payer whom Morpho partly liquidated has less collateral than recorded, so their next Adag payment is checked, and it is refused while they sit above 40%. That fails closed. A cash payment with no new debt is never blocked by a price drop. Proven against the deployed contract on mainnet state by the attack suite (packages/contracts/deployments/attacks-2026-09-25.md): after a simulated 25% price drop, a cash payment with the loan at 50.78% goes through while a small new borrow is refused (A9a, A9b), and borrowing to about 50% in the same batch as a payment is refused (A3).
Residual, named: the check runs at the moment Adag's step executes. A payer who hand-builds a batch can still borrow more or withdraw collateral after that step, putting only their own position at risk (see the non-goal "A payer who goes above 40% by using Morpho directly"). The residual lasts only until that payer's next Adag payment. The extra debt was never recorded, so it counts as new debt then, and the payment is refused while it sits above 40%. test_residual_borrowAfterPayIsNotCaught asserts the first half (the batch succeeds and nothing is recorded). test_unseenDebt_above40IsRefused shows the second (unrecorded debt above 40% is refused on the next payment). A payer whose debt Adag has never seen is checked on their first payment, so existing Morpho borrowers above 40% cannot pay through Adag until they are below 40%.
C11. The payment step reverts if any read the check needs reverts, if collateral value computes to zero while debt is above zero, or if a fixed market id does not resolve to the expected loan and collateral tokens. Unreadable means over the limit.
C12. The market params Adag passes to Morpho are exactly the ones Morpho returns for the fixed market ids. No argument from the caller can select, alter or substitute a market.
C13. The pledge size the app proposes is a suggestion with a stated margin; the contract's check in C10 is the guard. If the price moves between page load and the block, the transaction reverts. That is never a loss and never a silent over-borrow.
C24. New debt is accepted only if every nonzero feed that the market oracle's price() reads has a positive answer and an update time within its window: 26 hours for BTC/USD, 96 hours for EUR/USD. A fork test pins the oracle layout this assumes: no vaults, no second base feed, no second quote feed, and a quote feed only on the EURC market. Both oracles are immutable, and their addresses are part of each fixed market id, so the layout cannot change under a deployed Adag. (Added at the backend review; until then freshness was a design note, not a numbered invariant.)
Added at the final review (26 September)
A third review, made once the app was built, read its money paths, its API routes and the proof script. It proposed six invariants for the app layer that the standard above did not yet state. Each names the flow it guards and where it is upheld.
C25. Fee and principal from one balance. Before a signature is requested, the payer's USDC covers every USDC the batch moves out plus the worst-case fee, because Arc pays gas from that same balance. It guards every payment from a USDC balance, one bill or a basket of them. Upheld by simulateAndSend in packages/web/src/lib/wallet/send.ts: its usdcOut check requires the native balance to be at least gas times maxFeePerGas plus the USDC the batch moves out. Prompted by a finding: paying from balance was offered with no fee reserve, so a payer holding exactly the bill amount was offered a payment Arc could not carry out. Fixed before release.
C26. Consent to the funding source. Whether a payment draws on a balance or opens a loan is the payer's choice and is never substituted by the app. A choice that becomes invalid is cleared, not replaced. It guards the basket of several bills, where the source is chosen per currency and the numbers refresh while the payer decides. Upheld by the funding-choice effect in packages/web/src/components/app/Basket.tsx. Prompted by a finding: the basket's 30-second refresh could switch a payer's chosen source from balance to bitcoin without a click. Fixed before release.
C27. Signer identity. No signature is requested unless the connected account equals the account every onBehalf, receiver and balance check used. It guards every batch the app asks a wallet to sign, including after the wallet switches accounts between reading and signing. Upheld by the connected-account check in simulateAndSend in packages/web/src/lib/wallet/send.ts.
C28. Interest accrual on repay. Any debt figure shown or approved is accrued to the current block from the market's rate and lastUpdate, with a margin only for the seconds before the block. It guards closing a loan and every debt figure on the wallet page. Upheld by accrueBorrowAssets and closeApproval in packages/web/src/lib/pay/loan.ts. Prompted by a finding: the loan close sized its approval from Morpho's stored totals, which leave out the interest since the market's last update, so a close could revert for want of a few units of approval. Fixed before release.
C29. The displayed amount bounds the approval. Every approval the app builds is at most what the payer was shown, so a wrong upstream answer can only make a payment revert. It guards every payment and loan batch. Upheld by the builders in packages/web/src/lib/pay/build.ts, which take each approval exactly from the bill record the payer was shown.
C30. Over-approval reset. An approval above the amount used is reset to zero in the same batch, and no approval outlives a batch. It guards closing a loan, the one batch that approves more than it uses: the live debt plus a margin for accrual. Upheld by buildCloseLoan in packages/web/src/lib/pay/build.ts, whose last call approves 0.
Added before the second build (26 September), written before any of its code
A separate reviewer wrote this part before any code for four new components existed, working from a plain functional description of them: a second AdagBills deployment that lets an existing Morpho borrower enrol, AdagGuard, alerts, and Safe payments. The new bill contract keeps the name AdagBills. Below, v1 is the first deployment, which stays live, and v2 is the second. C31 to C57 are the definition of done for these components, and they are being built against it. Where a decision made after the review changed an invariant, the invariant says so.
Decided since the review.
- AdagGuard is the allowance design: the reserve stays in the borrower's wallet, approved to AdagGuard, which pulls only what a protection repays (C35).
- A rule is (trigger, target, expiry). There is no per-protection maximum and no cooldown. Each protection repays only what brings the loan back to the target, and the approval is the lifetime ceiling (C36, C38).
- Rule holders are an on-chain enumerable set in AdagGuard (C43).
- With two deployments that both number bills from 1, a bill's identity is the pair (contract, id), everywhere (C33).
Design gaps the review raised, most severe first
- AdagGuard's "maximum repayment per protection" is not a ceiling. protect is public and repeatable, so the true ceiling is the allowance. The review offered two fixes: add a per-rule cooldown, or state plainly that the allowance is the cap. Adag took the second (C38).
- The keeper's rule list fed by webhooks into a store: a forged "rule cleared" event silences a real borrower. Verify webhook signatures and rebuild from chain on a schedule; an enumerable set of rule holders in AdagGuard makes the rebuild a paged read (C43, C44).
- v1 and v2 both number bills from 1. Identity must be the pair (contract, id) through one parser (C33).
- Safe transactions: gasPrice, gasToken, refundReceiver zero; the outer delegatecall only to the canonical MultiSendCallOnly; safeTxGas zero so an inner failure reverts instead of consuming the nonce (C51, C54).
- The Telegram link signature must name the chat it binds to, and the bot webhook must verify Telegram's secret token (C44, C46).
- The same-block enrol refusal is a speed bump: borrow and enrol in block N, pay in block N+1. Only the payer's own position is affected. Public wording: "a payment through Adag is never the action that takes a position above 40%" (C32).
- protect's repay cap must use debt rounded down while the loan-to-value test uses debt rounded up, or a full repayment underflows in Morpho (C36).
What this changes in C1 to C30
- C3's fixed target list grows (v2, AdagGuard, MultiSendCallOnly) and binds the Safe builder and the keeper.
- C5 and C16 name one Adag address. With two contracts, a bill is (contract, id), and "paid" is filtered by the bill's own contract (C33, C53).
- C25's fee reserve must count a pending protection as an outflow (C45).
- C21's rate-limit shape applies to the keeper (C42).
- The non-goal "watching the position after payment" is now half true: AdagGuard and alerts watch it, within the non-goals below.
- Authentication, CSRF and multi-tenant isolation, listed in section A as not applying, now apply: signed messages are the auth, unauthenticated store writes are the CSRF, and wallet-to-chat is private data (C46, C47, C49).
Open questions, to settle before the code
- Whether Multicall3From refuses a contract sender (known: CallFrom needs sender equal to tx.origin, which is why the Safe path exists).
- The Safe singleton, MultiSendCallOnly address and Transaction Service on Arc. Safe v1.4.1 was found live on Arc; the addresses are being confirmed.
- Whether a Safe can link alerts, since it signs messages only through EIP-1271.
- The current bill link format, which decides how a bare id is read (C33).
- Whether a standing USDC allowance pull can leave the borrower with no gas (copy in the non-goals).
A) App-class risk profile, extended
New classes, numbered on from the four in section A:
- A pull-payment executor. AdagGuard holds standing token allowances from many borrowers and has a public function that spends them. The allowance is the asset.
- An event-driven automation service with a hot wallet. The keeper reacts to webhooks and a schedule, reads a store it did not fully write, and signs with a server key.
- A sender of messages to third-party inboxes. The Telegram bot's name is a trust signal; anything that lets an attacker choose what it says, or to whom, makes it a phishing channel.
- A builder of transactions for a multi-signer account. The app assembles a batch a Safe executes hours later.
- A multi-tenant store of private mappings. Wallet to chat, link codes, thresholds, keeper state, behind server routes with no login.
Categories that bite, tied to flows:
- Allowance drain through protect to transferFrom to Morpho.repay: a market argument resolving to the wrong loan token, a repay onBehalf that is not the borrower, a second token path, a cap from the wrong figure, repeated calls.
- Griefing with the borrower's own money: repayment at a bad time, or emptying USDC needed for gas or a signed payment.
- Forged, replayed or dropped webhooks on the RPC-provider route and the Telegram route.
- Keeper gas drain: rules that look actionable in simulation and are no-ops on chain; thousands of rules.
- Hot key and secret exposure: keeper key, bot token (messages every linked borrower as Adag), RPC key, store credentials, webhook secrets; a NEXT_PUBLIC_ prefix ships any of them.
- Authentication without sessions: every write about a wallet needs a fresh, single-use signature from that wallet.
- Multi-tenant isolation and privacy: no route answers "is this wallet linked"; one address normalizer for keys.
- Message content injection: alerts carry fixed strings and numbers only, parse mode off.
- Code guessing and binding confusion in the link flow.
- Wrong-account and wrong-contract identity: two id spaces; in a Safe payment the payer is the Safe, the signer an owner.
- Multisig transaction shape: operation, gasPrice, gasToken, refundReceiver, safeTxGas, baseGas, nonce.
- Time-of-check to time-of-use: Safe proposals execute hours later; the keeper simulates at one block and lands at another.
- Numeric errors in the guard's target math: two roundings, a 1e36 oracle scale, three caps.
- Enrol as a bypass of the new-debt rule: bounded to the payer's own position, disclosed.
- Race conditions in the store: concurrent keeper runs, double use of a code.
Still not applicable: SSRF (fixed origins only, C57), SQL injection (no query language; key construction covered by C57), file upload (no files), reentrancy through tokens (no hooks; empty data to repay; nonReentrant anyway).
B) Threat model, extended
Trust boundaries, numbered on from the ten in section B:
- Internet to the RPC-provider webhook route.
- Internet to the Telegram webhook route.
- Internet or scheduler to the keeper run route.
- App routes to the store, and the store back to the keeper and alerts.
- Keeper server to the chain.
- Adag's bot to a borrower's phone.
- Any caller to AdagGuard.protect, and AdagGuard to the allowance, Morpho and the oracle.
- Any caller to AdagBills v2.enrol.
- Owner's wallet to Safe typed data, and the app to the Transaction Service and back.
- The Safe's execution, hours later.
- v1 to v2.
Attacker-controlled inputs, direct: protect's borrower, market and timing; setRule's fields including nonsense values; enrol's sender and timing; webhook bodies and headers and their replay; link codes as sent to the bot; signed messages posted to link, unlink or set thresholds; threshold values; the Safe address; every field of a Safe transaction; bill ids in a Safe basket, duplicates and both contracts.
Indirect: store rows read back later; Transaction Service responses; Telegram API responses; the oracle price and feed times at the landing block; the borrower's allowance and balance at the landing block; Morpho totals and shares at execution; v2's recorded position after an earlier enrol; the Safe's nonce, owners and threshold at execution; bill status at execution; the paid RPC's answers to simulations.
Privileged positions: AdagGuard's standing allowances and repay right (the most valuable addition); the keeper wallet (gas only, if no role); the server's secrets; the store; the app's Safe builder; the bot's identity.
Attacker goals, highest value first: drain a borrower's allowance; silence or misdirect protection and alerts; move a Safe's funds somewhere other than the bill; pay the wrong bill or the same bill twice across two contracts; use Adag's bot for phishing; spend the keeper's gas; pay from an over-40% position through v2 (bounded, disclosed).
C) Invariants C31 to C64, the definition of done for the second build
AdagBills v2
C31. Enrol scope. No arguments; writes only the caller's own recorded position with exactly Morpho's values in that block; stores the block; moves no tokens; emits exactly what it stored. A test enrols from an attacker and asserts every other record is unchanged.
C32. The line v2 keeps. In any transaction that pays a bill, if live shares are above or live collateral below the last accepted or enrolled position, the 40% check runs on live state. A payer whose enrol block equals the current block is refused. Named residual: enrol in block N, pay in N+1 forgives the debt in between, own risk only. Public wording: "a payment through Adag is never the action that takes a position above 40%". Tests show the one-batch attempt reverting and the two-block path succeeding.
C33. Bill identity across two contracts. A bill is (contract, id) in every link, row, key, Memo interpretation, paid read and pay call; one parser; ambiguous links refused. A test shows v1 id 5 and v2 id 5 cannot build each other's payment.
AdagGuard
C34. Rule ownership. Only the borrower (or the Safe as sender) writes or clears its rule; protect writes nothing but the borrower's Morpho debt.
C35. The only money path. Pulled equals repaid on the borrower's own position in a fixed, verified market in that market's loan token; the approval to Morpho is exact and consumed in the call; AdagGuard's balance is unchanged after the call; no other function moves or approves tokens; mistaken transfers are stuck by design. Fuzzed balance-identity test. Decided: the reserve stays in the borrower's wallet, approved to AdagGuard; there is no vault.
C36. Amount bounds. At most the least of: the amount that brings the loan back to the rule's target (debt rounded up after accrual, collateral value rounded down, as loanToValue), the remaining allowance, the borrower's balance, and debt in assets rounded down. Zero means nothing moves and nothing is emitted. A full-repayment test does not revert on rounding. Amended: the review's list also held a per-rule maximum, dropped when rules lost their maximum (C38).
C37. Price basis. The trigger uses the market oracle's price() with no freshness gate, the price liquidation uses; a reverting price reverts protect; a zero price stays bounded by C36; documented.
C38. The real ceiling. A rule is (trigger, target, expiry) and nothing more. There is no per-protection maximum and no cooldown: each protect repays only what brings the loan back to the target (C36), so a second protect at the same price moves nothing, and the allowance is the lifetime ceiling. A per-rule cooldown was considered and dropped: in a fast fall it could block the second protection the loan needs, at the moment the guard exists for. The app never builds an unlimited or larger-than-typed approval; the screen says the most it can take is the allowance, and that a USDC rule can leave the wallet without gas.
C39. No privileged role. No owner, keeper role, pause, sweep or upgrade; the keeper can build exactly one calldata shape, protect(borrower, market) to the fixed address with a fixed market and zero value; tested.
C40. Views equal action. The "would protect now, by how much" view and protect share one computation; the keeper and the app act only on the view at the latest block; property test.
C41. Rule validity. Refuse target at or above trigger, zero trigger, trigger at or above the liquidation line, or a past nonzero expiry; expired rules are inert; the app warns when a new rule would act immediately. Amended: the review's zero-maximum check went with the per-rule maximum.
C42. Keeper spend and concurrency. A written worst-hour gas cap, per-borrower limits and backoff; single-flight runs with a lease and expiry; bounded borrowers per run ordered by loan-to-value; gas limit from a latest-block simulation; no lease, no signing.
C43. The index is a hint. The rule-holder list is rebuilt from chain, from AdagGuard's on-chain enumerable set of rule holders read in pages, at a fixed cadence; no store row is ever the reason to pull tokens; a forged or missed event delays protection by at most one rebuild interval. Decided: the review allowed either filtered logs or an on-chain set; Adag keeps the set.
C44. Webhook authenticity. Provider signature, Telegram secret token and cron secret verified over the exact bytes; body caps, timeouts, rate limits; unverified requests dropped without logging bodies; handlers idempotent.
C45. A pending protection is an outflow. The pre-signature balance check counts a rule that would act now.
Alerts
C46. Link binding. A code from a cryptographic source with at least 128 bits, expiring in minutes, consumed atomically; the chat id is the one Telegram delivered it from on a verified request; a strict signature recovers to the wallet over a message naming the action, site, chain 5042, code, chat handle and id, and expiry; the wallet is never taken from a form; attempts rate-limited; a wrong code reveals nothing.
C47. Signed writes only. Every change to per-wallet server state carries a fresh signed message with a nonce and expiry, consumed once; unlinking from inside the chat needs no signature; thresholds validated as integers in a sane range.
C48. Alert content. Fixed strings, computed numbers and the fixed origin only; no chain free text; parse mode off; tested with HTML and Markdown references.
C49. Privacy of the link table. No route reveals whether a wallet is linked or any chat id; logs never hold codes or chat ids beside wallets; one normalizer for addresses and chat ids as keys.
C50. Alerts computed like the contract, promised like weather. Loan-to-value from loanToValue or the same arithmetic; best effort; a false alert can never cause a transaction.
Safe payments
C51. Safe transaction shape. To the canonical MultiSendCallOnly with delegatecall for that target only; inner plain calls to build-time addresses only; value 0, gasPrice 0, gasToken 0, refundReceiver 0, safeTxGas 0, baseGas 0; approvals exact to Morpho, AdagBills or AdagGuard; every onBehalf and receiver the Safe; no field from the service, a URL or an RPC; tested like C3.
C52. Safe verification before signing. Chain 5042; the address is a Safe of version 1.3.0 or later on chain; the connected account is an owner by isOwner; the nonce from the Safe contract; the signed hash equals getTransactionHash and the service's echo; EIP-712 only.
C53. Status truth. Paid comes from the bill's own AdagBills contract, never the service's executed flag or a MultiSend event.
C54. Atomic at execution. safeTxGas 0 and gasPrice 0 make any inner failure revert the whole execution without consuming the nonce; re-simulate from the Safe before the first signature; say the outcome is decided at execution.
C55. Same rules, Safe as payer. Enrol, setRule, approvals and pay by a Safe follow C31 to C41, C45 and C58 with the Safe as the account; an owner's wallet is never substituted for the Safe.
Server and secrets
C56. Secrets stay on the server. Keeper key, bot token, RPC key, store credentials, webhook and cron secrets only as server environment variables, none NEXT_PUBLIC_, checked in the build; the keeper wallet holds gas only.
C57. Routes bounded, fixed origins only. Body caps, timeouts, rate limits; fetches only to the RPC, Telegram, Safe's service and the store; every webhook, signed-message or service value parsed once into typed values before use; unparseable input dropped.
Added at the code review of the second build
C58. A payment never trips the payer's own guard unseen. Before a payment that borrows is signed, the app compares the loan-to-value it lands at with the payer's guard trigger for that market; at or above the trigger, it says in plain words that the guard will repay about how much of the wallet's USDC or EURC within minutes, before the signature. When the rule or the approval cannot be read, the payment is blocked with a plain sentence, and both are read again at the moment of signing.
C59. Keeper paging and filtering. Each run reads a fixed number of holder pages from a stored cursor that wraps around; holders with no debt or no allowance are dropped with cheap reads before any quote; a fixed number are simulated and acted on per run; no holder can make the cursor skip another holder or loop.
C60. Stopping removes the approval. Clearing a rule in the app sets AdagGuard's approval to 0 in the same transaction, proven by reading the allowance back as 0; every screen that shows a rule shows the standing allowance beside it; a leftover approval with no rule stays on screen until it is 0.
Added at the final review
C61. Rate limits never block their own flow. Each route counts in its own bucket; a read or a poll that runs to its cap never spends the budget of the write it leads to. Tested by polling to the cap and then writing.
C62. Error text is an output. Every error a route returns is a fixed sentence or text this codebase wrote. No text from a library, an RPC, a service or fetch reaches a response, since it can carry a keyed URL or an upstream's words.
C63. A run fits its time limit. The worst-case keeper or webhook run, receipt waits and alerts included, fits inside the route's platform time limit, and the lease outlives that worst case, so a run is never cut off while another can start the same work.
C64. The paid index never skips a block. A block range counts as scanned only when the head and the logs come from the same endpoint and the range ends a safety margin below that endpoint's head. A failed or partial answer leaves the cursor where it was.
General standards applied. Market validity is v1's predicate; authority over server state is a strict signature; webhook validity is the provider's signature over the raw body; Safe targets are the build-time set plus MultiSendCallOnly; bill identity is a pair from one parser; debt has two roundings from one function; the view and protect share one computation; the three Safe hashes compared as the same bytes32; one normalizer for keys; thresholds in WAD units.
Named non-goals. Liquidation itself (no balance, no allowance, nobody calling, or a single jump past 86% defeats the guard; interest can cross the trigger between price updates); a borrower's own choice to sit above 40% through enrol across two blocks, or to fund a rule with gas money; the oracle being right; a malicious Safe owner; the availability or honesty of Safe's service, Telegram or the RPC provider; a compromised Telegram account or phone; a compromised server beyond what it holds; privacy of on-chain facts.
Rendering and outputs
C14. The reference is stored, emitted and rendered as opaque bytes shown as plain text.
- It is never HTML, markdown, a link, or anything clickable.
- Bidirectional control characters are stripped or escaped for display.
- Invalid UTF-8 renders as replacement characters and never breaks the page.
This holds on the bill page, both dashboards, the Memo data the app writes, and any indexer output. The contract enforces the 140-byte cap, counted in bytes.
C15. The payee address and amount the payer sees on the bill page are decoded, by the same code, from the same bytes the transaction will use: the on-chain record, or the verified signed bill.
- Amounts stay integers in base units end to end, and are formatted for display with the token's decimals.
- A decimal typed into a form is parsed once, at creation, and never re-parsed.
- Addresses are shown in full and checksummed.
C16. Payment status, and "paid by which transaction", come only from Adag's own storage or Adag's own event. The event is filtered by Adag's address and event signature, and tied to a transaction hash and log index. Memo events, GraphQL figures, and indexer rows that cannot be traced to an Adag event are decoration, never proof.
C17. Every value Adag emits in an event (id, payee, currency, amount, payer) has already passed C7, C8 and C10. A downstream consumer acting on the event cannot be fed a value the contract did not accept.
Upstream data
C18. Values from the public RPC, Morpho's GraphQL, an indexer or a swap quote are used only to display and to propose a pledge size. None is ever placed into a transaction as an address, selector, chain id or receiver. A wrong upstream value can cause a revert (C10, C1), never a loss.
C19. Every upstream fetch has an explicit timeout and a response size cap. Log queries are paged within the RPC's 10,000-block window, with a bounded page count. A failed or malformed response renders as "unavailable", never as zero, unpaid or paid.
Batch and limits
C20. Any single failure inside a batch reverts the whole batch, so no bill in a batch can end up paid while another in the same batch is not.
Decided at the gate (25 September): there is no on-chain batch function. A multi-bill payment is several Memo-wrapped pay calls in one Multicall3From batch, every call with allowFailure false. Each bill keeps its own memo, and the batch is all or nothing. The app caps the bill count per batch, sized against the 30M block gas limit.
Fee sponsor (only if built)
C21. The sponsor's hot wallet submits only transactions it decoded itself: a call to Adag or Multicall3From carrying a valid, unpaid bill, for which it holds the payer's signed authorization.
- It simulates before sending, and takes its fee in the same transaction.
- Each authorization binds the bill id, payee, currency, amount, the sponsor's address and a deadline, and can be used once.
- Per-address and global rate limits cap the sponsor's worst-hour loss at a written number of USDC.
- The payer's tokens can move only to the bill's payee and to the sponsor's fee address, in the amounts signed.
C22. Under EIP-7702, the delegation the app asks a payer to sign points only to an implementation fixed at build time, whose code cannot move funds except as C2 allows. The app never requests a delegation to an address from a URL or an upstream response. Before this path is offered, a fork test verifies whether a delegated account still counts as a plain wallet for Arc's CallFrom.
Admin (only if built)
C23. No owner-settable value can redirect funds, mark a bill paid, change an existing bill's payee or currency, or weaken the limit for a payment already in flight. Owner changes emit an event and take effect after a delay long enough for payers to see them. Choice: with no admin this holds by having nothing to set. An owner makes C10, C12 and C23 depend on storage, and adds a key to steal.
General standards
- Primitives over lists.
- Currency validity means "equals the loan token of one of the two fixed market ids, as returned by Morpho", not a hand-kept token list. Covers fake tokens, cirBTC as a currency, and tokens added later. Does not cover an issuer upgrading a real token's behaviour.
- Signature validity means the standard library's strict recovery, which rejects address zero, high s values and wrong lengths. Covers malleability and garbage signatures. Does not cover a payee whose key is stolen.
- Reference validity means byte length, enforced on chain. Covers oversize. Does not cover meaning: phishing text is valid bytes, and C14 handles display.
- Address display uses the library checksum. Covers transcription errors. Does not cover look-alike addresses with vanity prefixes.
- Target validity in the builder means "equals one of the build-time constants". This is a list by nature, but a list of our own deployments, not of the world. It is tested by asserting that no other address can appear in the calldata the app builds.
- Normalize before you compare.
- The bill id comes from one encoding owned by the contract. The UI recomputes it with the same encoding and checks equality before showing it (C5).
- Amounts are integers in base units from creation to event. Display only formats; it never parses (C15).
- The payee shown is decoded from the same bytes the contract hashes (C15).
- The loan check compares debt and collateral value in the same units, after the same conversions. The UI's preview uses the same formula as the contract, read from the contract where possible (C10, C13).
- Validate outputs like inputs. Events (C17), Memo data (C14), links the app generates (C3, C5) and indexer rows (C16) are each treated as live input to whatever reads them next.
- Fail closed.
- Unreadable state reverts (C11).
- A chain mismatch means no transaction (C4).
- Batches have a cap and revert whole (C20).
- Approvals are exact, and go to two spenders only (C3).
- allowFailure is always false (C3).
- An upstream failure renders "unavailable" (C19).
- A void beats a pay (C9).
- Reads and pages are bounded (C19).
- Named non-goals. Adag does not defend against:
- The identity of a payee. A bill from an attacker who claims to be your landlord is a valid bill. Adag shows exactly who and how much; judging the who is the payer's job.
- Morpho, the oracles or the token contracts being wrong, paused, blocklisting or upgraded. Adag fails closed on their reverts and zeros, nothing more.
- A compromised wallet, browser or operating system.
- A compromised page build or hosting. Pinned dependencies, a strict content security policy, no third-party scripts and no runtime-loaded code reduce the odds; they do not remove them. A payer signing a Multicall3From blob cannot detect a compromised page.
- Watching the position after payment. Interest accrues, prices move, and Morpho liquidates at 86%. Adag checks the line once, at payment, and never again.
- A payer who goes above 40% by using Morpho directly. Adag's line applies to payments made through Adag.
- Privacy. Every bill, amount, reference and payer is public on chain and in every link.
- Front-running or MEV. Adag's flows have no slippage to extract. The swap flow inherits Uniswap's or App Kit's own behaviour.
- Availability of the public RPC or Vercel.
- How third-party explorers render Memo data.
- Enforcing due dates. The due date is information unless a later decision makes it a rule.