Intro
We found a critical vulnerability in the live ParyonUSD contracts, and we have fixed it on mainnet. It was never exploited, so the system was unaffected and kept running throughout. You don’t need to do anything: loans, stakes and redemptions work exactly as before, and the app has already been updated. For readers who want the inner workings, the addendum at the end covers the technical details.
The vulnerability
Every loan has a loan id, and each id is supposed to be unique. The bug was an exploitable edge case: a borrower could deliberately open a second loan with the same id as their first. A duplicate id on its own did no harm. To exploit it, the borrower had to follow up with a redemption: start it on one loan, then settle or cancel it against the other. That left both loans in a state where neither could be liquidated.
Both loans had to be the borrower’s own, so nothing could be taken directly from other users or from the stability pool. The risk was bad debt: a loan that can’t be liquidated stays open even when a falling BCH price leaves it without enough collateral. No loan id was ever actually duplicated.
How we found it
We found the bug during an AI-assisted security review of the V2 contracts, which still shared V1’s design for loan ids. The same review then showed the bug was present in the V1 contracts running on mainnet. It had been there since launch, and wasn’t caught by the audits or by our original formal verification.
We confirmed it with formal verification, the mathematical checking of the contracts we mentioned in State of PUSD #1. Originally it only checked the system’s privileged tokens. We extended it to check that each loan id can back only one loan, and against the live contracts it found the exploit by itself.
How it was fixed
The loan contracts can’t be changed, but the price contracts can: they were designed to be upgradable with the oracle migration key, so the oracle can evolve over time. Every new loan passes through a price contract, which made it the place to fix this. The upgraded price contract adds a single check to opening a loan, which rules out the duplicate id. Nothing else changed, and every other contract is untouched.
We moved all 5 mainnet price contracts to the new code at block 972,003, and they have been running stably since. Anyone can confirm the fix is live with verify_contract_deployment.
Now open source
- Contracts
1.1.0. The upgraded price contract is in the public contracts repo and in@paryonusd/contracts@1.1.0on npm. Itspost-audit-changes.mdexplains the change, and every other artifact’s bytecode is identical to1.0.0. - Deployment verification. verify_contract_deployment checks the upgraded deployment.
- Formal verification. We have open-sourced the formal verification tool, with the extended rule. It runs on the compiled contracts themselves, and keeps the original
1.0.0contracts as a test case that must keep finding this bug.
V2 will have formal verification from day one, run on its contracts before launch rather than after.
The migration key
Working on this fix also made us look closely at the migration key itself. The key was always a point of trust: together with the oracle, it decides which prices the system accepts. It holds no funds, and every other contract stays immutable. What our contract documentation initially didn’t account for is that in V1 a migration reaches further than the price: new price code could also change rules elsewhere in the system (see the addendum), and V1 lets a migration happen without any delay or advance announcement. We have since documented this in the contracts repo.
Looking ahead to V2
V2 changes how migrations work in two ways. First, as we described in Introducing ParyonUSD V2, every migration has to be announced on-chain first, with a stated reason, and can only go through after a notice period of about two days. That way everyone can see a change coming before it takes effect. Second, V2 properly limits the power of the migration key: a migration can change the price contracts, which decide which prices the system accepts, but nothing else in the system. V2’s formal verification checks that this holds even if an attacker gets hold of the migration key.
Addendum: Technical Details
This addendum covers the protocol’s inner workings, for developers and for anyone who wants to check our work.
The loan id collision
A loan’s id is the token id of its loan key, and redemptions find the loan they target by that id alone. To show that a loan key is new, the factory that creates it also creates an “origin proof” token, which the borrow checks and spends. That is where the bug was: the borrow never required the proof to be destroyed. A borrower could keep it, mint a copy of their own loan key, and present the pair in a second borrow, opening a second loan with the same id as their first.
With two loans sharing an id, a redemption started on one loan could be settled or cancelled against the other. That left both loans in a broken state in which they could no longer be liquidated, charged interest or closed, and the attacker could switch that state on and off at will.
The attack starts with a borrow that keeps its origin proof, which anyone can see on-chain. We checked every borrow since launch, and again right before the upgrade: none ever kept its proof, and no loan id has ever backed two loans.
The formal verification
The original formal verification covered one property: no transaction, however it is constructed, can take the protocol’s privileged tokens, the minting and mutable NFTs that carry authority in the system. The origin proof is an ordinary immutable NFT, so it was outside that property. We extended the verification to ordinary NFTs with a new rule: each origin proof backs exactly one loan. Against the live contracts it failed: without being given the attack, the solver built the borrow that keeps its origin proof.
The fix
Every borrow has to spend a price contract alongside it, and an origin proof can only be spent by a borrow. The upgraded price contract adds one rule to the function that shares the price with other contracts: in a borrow, the outputs a borrower is free to use may only hold tokens of the new loan’s own key. The origin proof has nowhere left to go, so every borrow now burns it. Otherwise the new price contract is the old one, with the same constructor, price updates and migration function, and the rule only runs in a borrow, so price updates, liquidations, redemptions and staking never see it. Every other contract is untouched, with the same bytecode: they recognise the price contract by its token rather than its code, so they work with the new one unchanged.
Against the upgraded contracts the extended formal verification passes, and each of the 30 contract functions it models can still run, borrowing included. The verify_contract_deployment tool checks the five migrated price contracts, along with the absence of kept origin proofs and of shared loan ids.
The migration key’s reach
In V1 the price contracts share their token category with the loans and the loan functions, so migrated price code could turn its token into a loan function. And because the other contracts leave the price contract’s own output for it to check, new code could also let a copy of one of the system’s minting tokens out.
V2 gives the price contracts a token category of their own, which only the contracts that read the price accept, and has those contracts check the price output themselves. That is what limits a V2 migration to the price.
Join the Conversation
If you have questions about this fix, or want to dig into the formal verification yourself, we’d love to hear from you.
- Follow ParyonUSD on X: x.com/ParyonUSD
- Join the ParyonUSD group on Telegram: t.me/ParyonUSD
In our Telegram group you can engage directly with the team - ask questions, share your thoughts, and be part of the ongoing conversation as we build ParyonUSD together!
