← Writing
AUDIT

Gold Exchange Protocol


Security audit of Gold Exchange smart contracts identifying critical vulnerabilities in oracle implementation, minting logic, and economic design.
Findings
2
Critical
5
High
1
Medium

Owner Mint Bypasses Reserve Checks

Severity: Critical

Description

The GBAR.mint() admin function only checks that GbarVault is configured and amount > 0. It does not evaluate the vault’s GOLD reserves or the oracle at all. Any privileged entity (or a compromised owner key) can arbitrarily mint GBAR to the vault and sell it, instantly breaking the 85% collateral promise.

Impact

Full loss of peg: attackers can print infinite GBAR, dump it, and leave the system insolvent.

Attack Path

  1. Malicious owner calls setGBARVault to point at a wallet they control (optional).
  2. Call mint(hugeAmount) to mint GBAR straight into that wallet.
  3. Dump the unbacked tokens or use them to redeem physical gold, draining reserves.

Affected Components

  • contracts/GBAR.sol:172-193

Code Samples

// contracts/GBAR.sol:183-193
function mint(uint amount) public onlyOwner nonReentrant returns(bool) {
    if (address(GbarVault) == address(0)) revert GBARVaultNotSet();
    if (amount == 0) revert AmountCannotBeZero();
    _totalSupply += amount;
    _mint(address(GbarVault), amount);
    return true;
}

Proof of Concept (PoC)

// test/hardhat/audit.poc.ts:149-157
const backedAmount = calcGbarValue(BigInt(goldSupply), STALE_PRICE);
const unbacked = backedAmount * 20n; // 20x the collateral
await gbar.mint(toBN(unbacked));
expect(await gbar.totalSupply()).to.equal(toBN(unbacked));

Recommendations

  • Remove or heavily constrain direct admin minting; instead, route all supply changes through collateral-aware flows (goldValueMint with freshness checks).
  • If admin mint must exist (e.g., emergency), cap it tightly and require multi-sig plus post-mint proof of reserves.
  • Emit events showing reserve checks to aid monitoring.

Oracle Price Freshness Is Never Validated

Severity: Critical

Description

GoldPriceOracle stores the feed timestamp in lastUpdateTimestamp but getLatestPrice() simply returns the cached value whenever latestPrice != 0. None of the minting or stabilization entry points in GOLD or GBAR verify that the price was updated recently, so a halted oracle keeps being treated as authoritative forever. Because the system mints/burns GBAR using the USD value of gold, stale USD quotes immediately translate into incorrect collateralization decisions.

Impact

A single stale price can freeze the peg at an obsolete exchange rate, causing every subsequent mint, stake reward, or stabilization cycle to over-burn or over-mint GBAR. Users depositing or redeeming after a large metal price move can permanently lose value or receive unbacked tokens.

Attack Path

  1. Oracle maintainer stops calling setLatestPrice, leaving lastUpdateTimestamp stuck while gold’s market price drifts.
  2. After days or weeks, any privileged actor invokes mintGoldAndGbar, stakeMint, or stabilize.
  3. The protocol blindly uses the stale USD quote, so every supply adjustment is computed with obsolete data.

Affected Components

  • contracts/oracles/GoldOracle.sol:10-87
  • contracts/GOLD.sol:111-155
  • contracts/GBAR.sol:593-624

Code Samples

// contracts/oracles/GoldOracle.sol:10-41
uint public lastUpdateTimestamp;

function getLatestPrice() public view returns (int) {
    if (latestPrice == 0) {
        revert GoldPriceNotSet();
    }
    return latestPrice;
}

Proof of Concept (PoC)

// test/hardhat/audit.poc.ts:86-95
it("accepts months-old oracle values during stabilization", async function () {
    await gold.mintGoldAndGbar(owner.address, 1000);
    await increaseTime(60 * DAY); // > 2 months
    const now = (await ethers.provider.getBlock("latest")).timestamp;
    expect(now - (await goldOracle.lastUpdateTimestamp())).to.be.gt(50 * DAY);
    await expect(gbar.stabilize()).to.not.be.reverted; // stale price still accepted
});

Recommendations

  • Enforce a configurable MAX_PRICE_AGE in getLatestPrice() (or at every call site) and revert when block.timestamp - lastUpdateTimestamp exceeds it.
  • Consider sourcing the feed from a battle-tested provider (Chainlink) and aggregating fallback feeds.
  • Emit and persist the price age that stabilize, mintGoldAndGbar, and stakeMint used so off-chain monitors can catch anomalies.

Stabilize Burns Healthy Supply When Oracle Undervalues Gold

Severity: High

Description

GBAR.stabilize() compares gbarTotalSupply against gbarValue computed with the oracle price and burns the difference whenever supply exceeds the computed target. If the oracle is stuck on an old, low USD price, gbarValue is artificially small and the function happily burns otherwise correctly backed GBAR from the vault.

Impact

The on-chain supply shrinks even though the vault is fully collateralized, forcing redemptions to fail, squeezing the secondary-market price upward, and permanently harming users who minted at the correct price.

Attack Path

  1. Oracle price lags the real market and reports $2,000/oz while the vault actually holds gold worth $4,200/oz.
  2. Supply was minted using the real price (e.g., via emergency mint after fixing the oracle off-chain).
  3. Once the 28-day timer unlocks, stabilize() is called and burns gbarTotalSupply - gbarValue(stale price) from GbarVault, destroying healthy liquidity.

Affected Components

  • contracts/GBAR.sol:593-624

Code Samples

// contracts/GBAR.sol:593-624
(uint goldPriceGram,, uint gbarValue) = GoldOracle.getGoldGbarConversion(goldTotalSupply);

if (gbarTotalSupply > gbarValue) {
    uint amountToBurn = gbarTotalSupply - gbarValue;
    if (vaultBalance >= amountToBurn) {
        _burn(address(GbarVault), amountToBurn);
    } else {
        _burn(address(GbarVault), vaultBalance);
    }
}

Proof of Concept (PoC)

// test/hardhat/audit.poc.ts:96-109
const backedSupply = calcGbarValue(BigInt(goldSupply), FRESH_PRICE); // real $4,200 price
await gbar.mint(toBN(backedSupply));
await increaseTime(29 * DAY);          // allow stabilization
await gbar.stabilize();                // burns down to calc(..., STALE_PRICE)
expect(await gbar.totalSupply()).to.equal(calcGbarValue(BigInt(goldSupply), STALE_PRICE));

Recommendations

  • Require a fresh oracle price before running stabilization (see Issue #1).
  • Include tolerance bands so minor oracle deviations don’t annihilate supply immediately.
  • Consider storing the USD value of collateral at mint time so stabilization can reason about historical prices instead of trusting a single snapshot.

Stabilize Mints Unbacked GBAR When Oracle Overvalues Gold

Severity: High

Description

The same stabilization logic mints amountToMint = gbarValue - gbarTotalSupply whenever the oracle implies that supply is too low. When the oracle is stuck on a high USD price while gold has fallen, this branch mints free GBAR even though the vault cannot back it.

Impact

Attackers monitoring the stale price can front-run a stabilization call, receive newly minted GBAR, and dump it before anyone notices it is unbacked, directly harming peg credibility.

Attack Path

  1. Oracle shows an old $4,200 price while the real market has dropped to $2,000.
  2. gbarTotalSupply still matches the real collateral.
  3. stabilize() mints gbarValue(high price) - gbarTotalSupply tokens to the vault, creating uncollateralized inventory ready for sale.

Affected Components

  • contracts/GBAR.sol:593-624

Code Samples

// contracts/GBAR.sol:620-624
} else if (gbarValue > gbarTotalSupply) {
    uint amountToMint = gbarValue - gbarTotalSupply;
    mint(amountToMint);
}

Proof of Concept (PoC)

// test/hardhat/audit.poc.ts:111-125
const staleSupply = calcGbarValue(BigInt(goldSupply), STALE_PRICE); // real price is low
await gbar.mint(toBN(staleSupply));
await goldOracle.setLatestPrice(ethers.BigNumber.from(FRESH_PRICE.toString())); // stale high quote
await increaseTime(29 * DAY);
await gbar.stabilize(); // totalSupply jumps to calc(..., FRESH_PRICE) with no new gold

Recommendations

  • Couple stabilization with trusted price feeds and multiple sources; pause minting if the oracle age or deviation exceeds thresholds.
  • Require proof of physical gold balances (vault attestations) before minting as part of stabilization.
  • Consider capping amountToMint per cycle to limit damage if the oracle lies.

mintGoldAndGbar Uses Stale Oracle Data

Severity: High

Description

GOLD.mintGoldAndGbar() mints GBAR equal to 85% of the USD value returned by _goldOracle.getGoldGbarConversion(amount) without verifying how recent the quote is. When gold rallies, depositors continue receiving the lower USD amount, while a sudden drop rewards them with too many tokens.

Impact

Depositors are systematically overcharged or underpaid relative to the 85% promise, directly impacting collateralization and user trust.

Attack Path

  1. Oracle stops updating while gold price doubles.
  2. Admin mints GOLD and GBAR to onboard new metal deposits.
  3. _goldOracle.getGoldGbarConversion keeps returning the old USD price, so depositors get only half the GBAR they are due.

Affected Components

  • contracts/GOLD.sol:111-127

Code Samples

// contracts/GOLD.sol:111-127
(,,uint gbarValue) = _goldOracle.getGoldGbarConversion(amount);
bool success = _gbar.goldValueMint(gbarValue);
require(success, "Error minting GBAR");

_mint(to, amount);

Proof of Concept (PoC)

// test/hardhat/audit.poc.ts:127-136
await gold.mintGoldAndGbar(owner.address, 500);
const minted = (await gbar.balanceOf(gbarVault.address)).sub(before);
expect(minted.toString()).to.equal(calcGbarValue(500n, STALE_PRICE));
// mismatches calcGbarValue(..., FRESH_PRICE) when real price is higher

Recommendations

  • Check block.timestamp - _goldOracle.lastUpdateTimestamp() before minting and revert if the feed is stale.
  • Allow the caller to pass in a signed price proof (e.g., Chainlink latest round) so the contract verifies freshness on-chain.
  • Log the price used per mint for auditability.

stakeMint Rewards Are Calculated From Stale Data

Severity: High

Description

GOLD.stakeMint() mints GBAR staking rewards based on _goldOracle.getGoldGbarConversion(amount) under the same assumptions as mintGoldAndGbar. When the price feed lags, stakers receive the wrong USD-equivalent reward and the staking pool balance departs from expectations.

Impact

The staking program can either be drained (when oracle price is high and real price is low) or underpay legitimate stakers (when the real price is higher), destabilizing incentives.

Attack Path

  1. Price feed is stale during a gold spike.
  2. Admin calls stakeMint for a marketing campaign.
  3. Stakers only receive rewards priced at the old lower price.

Affected Components

  • contracts/GOLD.sol:130-162
  • contracts/GBAR.sol:198-218 (called indirectly)

Code Samples

// contracts/GOLD.sol:138-156
(,,uint gbarValue) = _goldOracle.getGoldGbarConversion(amount);
bool gbarMintSuccess = _gbar.goldValueMint(gbarValue);
require(gbarMintSuccess, "Error minting GBAR");

_mint(address(this), amount);
_stakeVault.mintStake(amount, staker);

Proof of Concept (PoC)

// test/hardhat/audit.poc.ts:138-147
await gold.stakeMint(user.address, 250);
const minted = (await gbar.balanceOf(gbarVault.address)).sub(before);
expect(minted.toString()).to.equal(calcGbarValue(250n, STALE_PRICE));

Recommendations

  • Share the same freshness guardrails as Issue #4.
  • Consider averaging prices over multiple recent updates so a single stale value cannot skew rewards.
  • Record the price used per reward cycle for later reconciliation.

GOLD Withdrawals Never Burn Supply

Severity: High

Description

Neither GOLD nor GoldVault.withdrawTo() ever burn tokens when physical gold leaves custody. Withdrawals just transfer GOLD back to the recipient while total supply is unchanged, so a gram that left the vault still has a live token in circulation. This breaks the 1:1 mapping between outstanding GOLD and the underlying bullion and makes redemption math impossible.

Impact

Vault operators can re-use withdrawn tokens to mint GBAR or sell them while the corresponding metal is gone, turning the system into a fractional-reserve scheme without transparency.

Attack Path

  1. Owner mints GOLD representing 50 grams and user deposits it into GoldVault.
  2. Owner executes withdrawTo to send the tokens back when releasing the physical bar.
  3. Total supply remains 50, so the same tokens can be resold while the gold is no longer there.

Affected Components

  • contracts/GOLD.sol (no burn functionality)
  • contracts/vaults/GoldVault.sol:47-84

Code Samples

// contracts/vaults/GoldVault.sol:64-84
function withdrawTo(address to, uint amount) external onlyOwner nonReentrant {
    if (amount > GoldToken.balanceOf(address(this))) revert AmountExceedsBalance();
    GoldToken.transfer(to, amount); // no burn, supply unchanged
    emit WithdrawnTo(to, amount);
}

Proof of Concept (PoC)

// test/hardhat/audit.poc.ts:159-168
await gold.mint(owner.address, 50);
await gold.approve(goldVault.address, 50);
await goldVault.deposit(50);
const totalBefore = await gold.totalSupply();
await goldVault.withdrawTo(owner.address, 50);
expect(await gold.totalSupply()).to.equal(totalBefore); // still 50 despite physical exit

Recommendations

  • Add an owner-only burn function in GOLD and call it whenever physical gold is redeemed (either directly in withdrawTo or via a withdrawal workflow).
  • Track outstanding redemption requests separately and ensure their amounts are removed from total supply.
  • Publish regular proof-of-reserves matching on-chain supply to audited vault holdings.

Retrieval Requests Ignore Wallet Balances

Severity: Medium

Description

GBAR.createRetrievalRequest() lets a retrieval guard push any (from, amount) pair into the queue without checking balanceOf(from). Once enough guards confirm it, executeRetrievalRequest() tries to burn the requested amount from the user. If the user’s balance is smaller, _burn reverts with “burn amount exceeds balance”, leaving the request permanently unexecutable because there is no cancellation path.

Impact

A single malicious (or careless) guard can brick the retrieval queue for an address, forcing every execution attempt to revert and wasting gas. Operators cannot delete the bad request, so legitimate withdrawals for that user become impossible.

Attack Path

  1. Guard submits createRetrievalRequest(user, 10_000 GBAR) even though user holds 0.
  2. Guards confirm the request.
  3. Any attempt to execute the request reverts because _burn fails, and the stuck entry cannot be removed.

Affected Components

  • contracts/GBAR.sol:453-539

Code Samples

// contracts/GBAR.sol:463-481
function createRetrievalRequest(address from, uint amount) external onlyRetrievalGuard nonReentrant {
    if (from == address(0) || amount == 0) revert ...;
    retrievalRequests.push(RetrievalRequest({
        from: from,
        amount: amount,
        numConfirmations: 0,
        executed: false
    }));
}

Proof of Concept (PoC)

// test/hardhat/audit.poc.ts:170-180
await gbar.connect(guardOne).createRetrievalRequest(user.address, parseUnits("10", 6));
await gbar.connect(guardOne).confirmRetrievalRequest(0);
await gbar.connect(guardTwo).confirmRetrievalRequest(0);
await expect(gbar.connect(guardOne).executeRetrievalRequest(0))
.to.be.revertedWith("ERC20: burn amount exceeds balance"); // request now stuck forever

Recommendations

  • Add require(balanceOf(from) >= amount) to createRetrievalRequest (or at least to confirm).
  • Provide a cancellation path so guards can delete or adjust invalid requests.
  • Consider pulling funds into escrow before confirmations to guarantee executability.