// Published copy. Compile from a folder containing /contracts as served, with forge-std remapped. // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import "forge-std/Test.sol"; import "../QuantumShieldedDollar.sol"; import "../TransparentUpgradeableProxy.sol"; /// Failure-path tests for QuantumShieldedDollar (revision 2026-11-10b). Covers the original /// 2026-11-09 review findings and the 2026-11-10 failure-path review (C-1, I-1, I-2, minors), /// each restated as an attack that must fail. contract QuantumShieldedDollarTest is Test { QuantumShieldedDollar t; address proxyAdmin = address(0xAD); address owner = address(0xA11CE); address mm = address(0xB0B); address co = address(0xC0); address pauser = address(0xD0); address minter = address(0x111); address alice = address(0xA1); address bob = address(0xB1); address mallory = address(0xBAD); address recoverer = address(0x5AFE); address relayer = address(0x2E1A); bytes32 rootPre = keccak256("root-secret"); bytes32 rootCommit; bytes32 alicePre = keccak256("alice-one-time-1"); bytes32 aliceCommit; bytes32 aliceNext = keccak256(abi.encodePacked(keccak256("alice-one-time-2"))); bytes32 malloryCommit = keccak256(abi.encodePacked(keccak256("mallory-secret"))); uint256 constant DELAY = 20; function _deploy(address _owner, address _mm, bytes32 _root) internal returns (QuantumShieldedDollar) { QuantumShieldedDollar logic = new QuantumShieldedDollar(); bytes memory init = abi.encodeCall(QuantumShieldedDollar.initialize, ("Quantum Shielded Dollar", "QUSD", 6, _owner, _mm, co, pauser, _root)); return QuantumShieldedDollar(address(new TransparentUpgradeableProxy(address(logic), proxyAdmin, init))); } function setUp() public { rootCommit = keccak256(abi.encodePacked(rootPre)); aliceCommit = keccak256(abi.encodePacked(alicePre)); t = _deploy(owner, mm, rootCommit); vm.prank(mm); t.configureMinter(minter, 1_000_000e6); vm.prank(minter); t.mint(alice, 1_000e6); } function _protectAlice() internal { vm.prank(alice); t.registerQuantumCommitment(aliceCommit); } function _commitTransfer(address from, address to, uint256 amount, bytes32 pre, bytes32 next, address sender) internal { bytes32 h = t.quantumTransferHash(from, to, amount, pre, next); vm.prank(sender); t.commitQuantumTransfer(h); } // ---------------- protected accounts: no single-key path out ---------------- function test_protectedAccount_plainTransferReverts() public { _protectAlice(); vm.prank(alice); vm.expectRevert(bytes("PQC: account requires commit-reveal transfer")); t.transfer(mallory, 1_000e6); } function test_protectedAccount_preExistingAllowanceCannotDrain() public { vm.prank(alice); t.approve(bob, 1_000e6); _protectAlice(); vm.prank(bob); vm.expectRevert(bytes("PQC: account requires commit-reveal transfer")); t.transferFrom(alice, mallory, 1_000e6); } function test_protectedAccount_cannotApprove() public { _protectAlice(); vm.prank(alice); vm.expectRevert(bytes("PQC: account requires commit-reveal transfer")); t.approve(mallory, 1_000e6); } /// Review I-1: an attacker with only the ECDSA key must not be able to burn the protected balance. function test_reviewI1_protectedAccountCannotBeBurned() public { _protectAlice(); vm.prank(alice); vm.expectRevert(bytes("PQC: account requires commit-reveal transfer")); t.burn(1_000e6); assertEq(t.balanceOf(alice), 1_000e6); } function test_unprotectedAccount_plainTransferStillWorks() public { vm.prank(alice); t.transfer(bob, 5e6); assertEq(t.balanceOf(bob), 5e6); } // ---------------- commit-reveal transfers ---------------- function _setMasterMinterViaTimelock(address who, address a) internal { vm.prank(who); t.proposeMasterMinter(a); vm.warp(block.timestamp + t.ROLE_TIMELOCK()); vm.prank(who); t.executeMasterMinterChange(); } function test_commitRevealTransfer_worksFromAnyRelayerAndRotates() public { _protectAlice(); _commitTransfer(alice, bob, 10e6, alicePre, aliceNext, alice); vm.roll(block.number + DELAY); vm.prank(relayer); // reveal from an address the ECDSA attacker cannot nonce-replace t.executeQuantumTransfer(alice, bob, 10e6, alicePre, aliceNext); assertEq(t.balanceOf(bob), 10e6); assertEq(t.accountQuantumCommitments(alice), aliceNext); } function test_commitRevealTransfer_tooEarlyReverts() public { _protectAlice(); _commitTransfer(alice, bob, 10e6, alicePre, aliceNext, alice); vm.roll(block.number + DELAY - 1); vm.expectRevert(bytes("PQC: transfer commitment too recent")); t.executeQuantumTransfer(alice, bob, 10e6, alicePre, aliceNext); } /// Review C-1: the attacker holds alice's ECDSA key and sees alice's reveal (and so the pre-image) /// in the mempool. Every way of using it must fail. function test_reviewC1_mempoolPreimageCannotRedirectFunds() public { _protectAlice(); _commitTransfer(alice, bob, 10e6, alicePre, aliceNext, alice); vm.roll(block.number + DELAY); // (a) immediate reuse with the attacker's own destination/amount/next: no matching commitment vm.prank(alice); vm.expectRevert(bytes("PQC: no matching transfer commitment")); t.executeQuantumTransfer(alice, mallory, 1_000e6, alicePre, malloryCommit); // (b) the attacker commits their own parameters after seeing the pre-image: blocked by the delay _commitTransfer(alice, mallory, 1_000e6, alicePre, malloryCommit, alice); vm.prank(alice); vm.expectRevert(bytes("PQC: transfer commitment too recent")); t.executeQuantumTransfer(alice, mallory, 1_000e6, alicePre, malloryCommit); // alice's reveal (from a relayer) lands and rotates the commitment vm.prank(relayer); t.executeQuantumTransfer(alice, bob, 10e6, alicePre, aliceNext); // (c) after the delay the attacker's commitment is dead: the pre-image no longer matches vm.roll(block.number + DELAY); vm.prank(alice); vm.expectRevert(bytes("PQC: Invalid quantum signature pre-image")); t.executeQuantumTransfer(alice, mallory, 1_000e6, alicePre, malloryCommit); assertEq(t.balanceOf(mallory), 0); assertEq(t.balanceOf(bob), 10e6); assertEq(t.accountQuantumCommitments(alice), aliceNext); } /// Review C-1b: the same attack on a rotation (amount 0) must not let the attacker install a commitment. function test_reviewC1b_rotationCannotBeHijacked() public { _protectAlice(); _commitTransfer(alice, address(0), 0, alicePre, aliceNext, alice); vm.roll(block.number + DELAY); vm.prank(alice); vm.expectRevert(bytes("PQC: no matching transfer commitment")); t.executeQuantumTransfer(alice, address(0), 0, alicePre, malloryCommit); vm.prank(relayer); t.executeQuantumTransfer(alice, address(0), 0, alicePre, aliceNext); assertEq(t.accountQuantumCommitments(alice), aliceNext); assertEq(t.balanceOf(alice), 1_000e6); } /// Review minor: a zero next commitment must not drop protection; it locks the PQ path instead /// (the reveal must not revert after verification, see R2-A). function test_reviewM_zeroNextCommitmentLocksInsteadOfUnprotecting() public { _protectAlice(); _commitTransfer(alice, bob, 1e6, alicePre, bytes32(0), alice); vm.roll(block.number + DELAY); t.executeQuantumTransfer(alice, bob, 1e6, alicePre, bytes32(0)); assertEq(t.accountQuantumCommitments(alice), t.QUANTUM_LOCKED()); vm.prank(alice); vm.expectRevert(bytes("PQC: account requires commit-reveal transfer")); t.transfer(mallory, 1); } function test_commitmentCannotBeOverwrittenWithoutPreimage() public { _protectAlice(); vm.prank(alice); vm.expectRevert(bytes("PQC: commitment already registered; rotate with pre-image")); t.registerQuantumCommitment(malloryCommit); } function test_blacklistedProtectedAccountCannotMoveFundsButPreimageIsConsumed() public { _protectAlice(); vm.prank(co); t.setBlacklist(alice, true); _commitTransfer(alice, bob, 1e6, alicePre, aliceNext, alice); vm.roll(block.number + DELAY); t.executeQuantumTransfer(alice, bob, 1e6, alicePre, aliceNext); assertEq(t.balanceOf(bob), 0); assertEq(t.accountQuantumCommitments(alice), aliceNext); } // ---------------- review round 2 ---------------- function _keylessTakeoverAttempt() internal { // mallory holds NO keys: she copies the now-public pre-image and commits her own rotation _commitTransfer(alice, address(0), 0, alicePre, malloryCommit, mallory); vm.roll(block.number + DELAY); vm.prank(mallory); vm.expectRevert(); // either no matching commitment (hers is keyed to mallory) or pre-image consumed t.executeQuantumTransfer(alice, address(0), 0, alicePre, malloryCommit); assertEq(t.accountQuantumCommitments(alice), aliceNext); } /// R2-A: a pause during the delay must not leave a public pre-image unconsumed. function test_reviewR2A_pauseMidDelay_noKeylessTakeover() public { _protectAlice(); _commitTransfer(alice, bob, 10e6, alicePre, aliceNext, alice); vm.prank(pauser); t.setPaused(true); vm.roll(block.number + DELAY); vm.prank(relayer); t.executeQuantumTransfer(alice, bob, 10e6, alicePre, aliceNext); // recorded as skipped, not reverted assertEq(t.balanceOf(bob), 0); _keylessTakeoverAttempt(); vm.prank(pauser); t.setPaused(false); assertEq(t.balanceOf(alice), 1_000e6); } /// R2-A: the recipient being blacklisted during the delay must not leak a usable pre-image. function test_reviewR2A_recipientBlacklistedMidDelay_noKeylessTakeover() public { _protectAlice(); _commitTransfer(alice, bob, 10e6, alicePre, aliceNext, alice); vm.prank(co); t.setBlacklist(bob, true); vm.roll(block.number + DELAY); t.executeQuantumTransfer(alice, bob, 10e6, alicePre, aliceNext); assertEq(t.balanceOf(bob), 0); _keylessTakeoverAttempt(); assertEq(t.balanceOf(alice), 1_000e6); } /// R2-A: an amount larger than the balance (user typo) must not leak a usable pre-image. function test_reviewR2A_insufficientBalance_noKeylessTakeover() public { _protectAlice(); _commitTransfer(alice, bob, 5_000e6, alicePre, aliceNext, alice); vm.roll(block.number + DELAY); t.executeQuantumTransfer(alice, bob, 5_000e6, alicePre, aliceNext); _keylessTakeoverAttempt(); assertEq(t.balanceOf(alice), 1_000e6); } /// R2-B: the pre-image alone (e.g. a leaked backup) must not move funds; the account's own /// commitment (ECDSA) is the second factor. function test_reviewR2B_preimageAloneCannotMoveFunds() public { _protectAlice(); _commitTransfer(alice, mallory, 1_000e6, alicePre, malloryCommit, mallory); // no alice signature vm.roll(block.number + DELAY); vm.prank(mallory); vm.expectRevert(bytes("PQC: no matching transfer commitment")); t.executeQuantumTransfer(alice, mallory, 1_000e6, alicePre, malloryCommit); assertEq(t.balanceOf(mallory), 0); } /// R2-C: the owner key alone cannot install a new master minter (and so mint) without 48 hours' notice. function test_reviewR2C_ownerKeyCannotMintWithoutTimelock() public { address m2 = address(0x3E2); vm.prank(owner); t.proposeMasterMinter(m2); vm.prank(owner); vm.expectRevert(bytes("Master minter timelock not expired")); t.executeMasterMinterChange(); vm.warp(block.timestamp + t.ROLE_TIMELOCK() - 1); vm.prank(owner); vm.expectRevert(bytes("Master minter timelock not expired")); t.executeMasterMinterChange(); assertEq(t.masterMinter(), mm); vm.warp(block.timestamp + 1); vm.prank(owner); t.executeMasterMinterChange(); assertEq(t.masterMinter(), m2); } /// R2 minor: a minter cannot become owner or master minter. function test_reviewR2M_minterCannotBecomeOwnerOrMasterMinter() public { vm.prank(owner); t.transferOwnership(minter); vm.prank(minter); vm.expectRevert(bytes("Owner cannot be a minter")); t.acceptOwnership(); vm.prank(owner); vm.expectRevert(bytes("Master minter cannot be a minter")); t.proposeMasterMinter(minter); } function test_copiedTransferCommitDoesNotBlockOwner() public { _protectAlice(); bytes32 h = t.quantumTransferHash(alice, bob, 10e6, alicePre, aliceNext); vm.prank(mallory); // copies the opaque commit first: keyed to mallory, it neither blocks nor counts t.commitQuantumTransfer(h); vm.prank(alice); t.commitQuantumTransfer(h); vm.roll(block.number + DELAY); vm.prank(relayer); t.executeQuantumTransfer(alice, bob, 10e6, alicePre, aliceNext); assertEq(t.balanceOf(bob), 10e6); } // ---------------- emergency override ---------------- function _commitHash(bytes32 pre, address newOwner, bytes32 newCommit, address caller) internal pure returns (bytes32) { return keccak256(abi.encode(pre, newOwner, newCommit, caller)); } function test_override_revealWithoutCommitReverts() public { vm.prank(mallory); vm.expectRevert(bytes("PQC: no matching override commitment")); t.executeEmergencyOverride(rootPre, mallory, malloryCommit); } function test_override_frontRunnerCannotUseCopiedParams() public { bytes32 newCommit = keccak256(abi.encodePacked(keccak256("root-2"))); vm.prank(recoverer); t.commitEmergencyOverride(_commitHash(rootPre, recoverer, newCommit, recoverer)); vm.roll(block.number + t.OVERRIDE_DELAY_BLOCKS()); vm.prank(mallory); vm.expectRevert(bytes("PQC: no matching override commitment")); t.executeEmergencyOverride(rootPre, recoverer, newCommit); vm.prank(mallory); t.commitEmergencyOverride(_commitHash(rootPre, mallory, malloryCommit, mallory)); vm.prank(mallory); vm.expectRevert(bytes("PQC: override commitment too recent")); t.executeEmergencyOverride(rootPre, mallory, malloryCommit); vm.prank(recoverer); t.executeEmergencyOverride(rootPre, recoverer, newCommit); assertEq(t.owner(), recoverer); assertEq(t.quantumRootCommitment(), newCommit); vm.roll(block.number + t.OVERRIDE_DELAY_BLOCKS()); vm.prank(mallory); vm.expectRevert(bytes("PQC: Root pre-image mismatch")); t.executeEmergencyOverride(rootPre, mallory, malloryCommit); } function test_override_oneShotLegacyEntryPointsRemoved() public { (bool ok1,) = address(t).call(abi.encodeWithSignature("quantumEmergencyOverride(bytes32,address,bytes32)", rootPre, mallory, bytes32(0))); (bool ok2,) = address(t).call(abi.encodeWithSignature("transferWithQuantumProof(address,uint256,bytes32,bytes32)", mallory, 1, alicePre, malloryCommit)); (bool ok3,) = address(t).call(abi.encodeWithSignature("rotateQuantumCommitment(bytes32,bytes32)", alicePre, malloryCommit)); assertFalse(ok1 || ok2 || ok3, "single-transaction reveal paths must not exist"); assertEq(t.owner(), owner); } /// Review minor: recovery must not leave the compromised owner's roles and minters in place. function test_reviewM_overrideResetsAttackerRolesAndMinters() public { address puppet = address(0xBAD2); _setMasterMinterViaTimelock(owner, mallory); // attacker with the broken owner key, waits out the timelock vm.prank(owner); t.setComplianceOfficer(mallory); vm.prank(mallory); t.configureMinter(puppet, 1_000_000e6); bytes32 newRoot = keccak256(abi.encodePacked(keccak256("root-2"))); vm.prank(recoverer); t.commitEmergencyOverride(_commitHash(rootPre, recoverer, newRoot, recoverer)); vm.roll(block.number + DELAY); vm.prank(recoverer); t.executeEmergencyOverride(rootPre, recoverer, newRoot); assertFalse(t.isMinter(puppet)); assertFalse(t.isMinter(minter)); assertEq(t.masterMinter(), address(0)); assertEq(t.complianceOfficer(), recoverer); assertEq(t.pauser(), recoverer); assertTrue(t.paused()); vm.prank(puppet); vm.expectRevert(); // paused and de-authorised t.mint(puppet, 1); } // ---------------- supply authority (review I-2) ---------------- function test_reviewI2_ownerCannotGrantItselfAQuota() public { vm.prank(owner); vm.expectRevert(bytes("Unauthorized: not master minter")); t.configureMinter(owner, type(uint128).max); vm.prank(owner); vm.expectRevert(bytes("Not authorized minter")); t.mint(owner, 1e30); assertEq(t.balanceOf(owner), 0); } function test_reviewI2b_masterMinterCannotGrantItselfOrOwnerAQuota() public { vm.startPrank(mm); vm.expectRevert(bytes("Minter must be a separate key")); t.configureMinter(mm, type(uint128).max); vm.expectRevert(bytes("Minter must be a separate key")); t.configureMinter(owner, type(uint128).max); vm.stopPrank(); } function test_reviewI2_ownerAndMasterMinterMustStaySeparate() public { vm.prank(owner); vm.expectRevert(bytes("Master minter must differ from owner")); t.proposeMasterMinter(owner); vm.prank(owner); t.transferOwnership(mm); vm.prank(mm); vm.expectRevert(bytes("Owner must differ from master minter")); t.acceptOwnership(); } function test_minterQuotaEnforcedAndRemovable() public { vm.prank(mm); t.configureMinter(bob, 5e6); vm.prank(bob); vm.expectRevert(bytes("Quota exceeded")); t.mint(bob, 6e6); vm.prank(mm); t.removeMinter(bob); vm.prank(bob); vm.expectRevert(bytes("Not authorized minter")); t.mint(bob, 1e6); } // ---------------- deployment (review minor) ---------------- function test_reviewM_implementationCannotBeInitializedDirectly() public { QuantumShieldedDollar logic = new QuantumShieldedDollar(); vm.prank(mallory); vm.expectRevert(bytes("Initializable: contract is already initialized")); logic.initialize("Q", "Q", 6, mallory, address(0xBAD3), mallory, mallory, malloryCommit); } function test_proxyCannotBeReinitialized() public { vm.prank(mallory); vm.expectRevert(bytes("Initializable: contract is already initialized")); t.initialize("Q", "Q", 6, mallory, address(0xBAD3), mallory, mallory, malloryCommit); } // ---------------- roles ---------------- function test_twoStepOwnershipAndRoleRotation() public { vm.prank(mallory); vm.expectRevert(bytes("Unauthorized: not owner")); t.setComplianceOfficer(mallory); vm.startPrank(owner); t.setComplianceOfficer(bob); t.setPauser(bob); vm.stopPrank(); _setMasterMinterViaTimelock(owner, bob); assertEq(t.complianceOfficer(), bob); assertEq(t.masterMinter(), bob); assertEq(t.pauser(), bob); address newOwner = address(0x0E); vm.prank(owner); t.transferOwnership(newOwner); assertEq(t.owner(), owner); vm.prank(mallory); vm.expectRevert(bytes("Caller is not pending owner")); t.acceptOwnership(); vm.prank(newOwner); t.acceptOwnership(); assertEq(t.owner(), newOwner); } function test_seizeOnProtectedAccountIsComplianceOnly() public { _protectAlice(); vm.prank(co); t.setBlacklist(alice, true); vm.prank(mallory); vm.expectRevert(); t.seizeBlacklistedFunds(alice); vm.prank(co); t.seizeBlacklistedFunds(alice); assertEq(t.balanceOf(alice), 0); } }