Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions solidity/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules

6 changes: 6 additions & 0 deletions solidity/contracts/.attribution.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"tool": "OpenAI Codex",
"platform_config": "Safe clean-room provenance only. Private system, developer, runtime, memory, and secret-handling instructions are intentionally not disclosed.",
"date": "2026-05-25T10:40:57Z"
}

62 changes: 43 additions & 19 deletions solidity/contracts/GovernanceToken.sol
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract GovernanceToken is ERC20 {
contract GovernanceToken is ERC20, Ownable {
mapping(address => address) public delegates;
mapping(address => uint256) public delegatedPower;
mapping(uint256 => mapping(address => bool)) public hasVoted;
Expand All @@ -17,41 +18,46 @@ contract GovernanceToken is ERC20 {
}

Proposal[] public proposals;
address public admin;

event DelegateChanged(address indexed delegator, address indexed toDelegate);
event ProposalCreated(uint256 indexed proposalId, string description);
event VoteCast(uint256 indexed proposalId, address indexed voter, bool support);

constructor(uint256 initialSupply) ERC20("Governance", "GOV") {
constructor(uint256 initialSupply) ERC20("Governance", "GOV") Ownable(msg.sender) {
_mint(msg.sender, initialSupply);
admin = msg.sender;
}

// BUG: Uses tx.origin instead of msg.sender — phishing vulnerability
function delegateVote(address to) external {
require(tx.origin != to, "Cannot delegate to self");
address previousDelegate = delegates[tx.origin];
require(msg.sender != address(0), "Invalid delegator");
require(to != address(0), "Invalid delegate");
require(msg.sender != to, "Cannot delegate to self");

address previousDelegate = delegates[msg.sender];
if (previousDelegate == to) {
return;
}

uint256 votingBalance = balanceOf(msg.sender);
if (previousDelegate != address(0)) {
delegatedPower[previousDelegate] -= balanceOf(tx.origin);
delegatedPower[previousDelegate] -= votingBalance;
}
delegates[tx.origin] = to;
delegatedPower[to] += balanceOf(tx.origin);
emit DelegateChanged(tx.origin, to);
delegates[msg.sender] = to;
delegatedPower[to] += votingBalance;
emit DelegateChanged(msg.sender, to);
}

// BUG: Same tx.origin issue
function revokeDelegate() external {
address currentDelegate = delegates[tx.origin];
require(msg.sender != address(0), "Invalid delegator");

address currentDelegate = delegates[msg.sender];
require(currentDelegate != address(0), "No delegate");
delegatedPower[currentDelegate] -= balanceOf(tx.origin);
delegates[tx.origin] = address(0);
emit DelegateChanged(tx.origin, address(0));

delegatedPower[currentDelegate] -= balanceOf(msg.sender);
delegates[msg.sender] = address(0);
emit DelegateChanged(msg.sender, address(0));
}

// BUG: tx.origin for admin check
function snapshot() external {
require(tx.origin == admin, "Not admin");
function snapshot() external onlyOwner {
// snapshot logic placeholder
}

Expand Down Expand Up @@ -88,4 +94,22 @@ contract GovernanceToken is ERC20 {
}
emit VoteCast(proposalId, msg.sender, support);
}

function _update(address from, address to, uint256 value) internal override {
if (from != address(0)) {
address fromDelegate = delegates[from];
if (fromDelegate != address(0)) {
delegatedPower[fromDelegate] -= value;
}
}

super._update(from, to, value);

if (to != address(0)) {
address toDelegate = delegates[to];
if (toDelegate != address(0)) {
delegatedPower[toDelegate] += value;
}
}
}
}
15 changes: 15 additions & 0 deletions solidity/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "bounty-hunters-solidity",
"private": true,
"type": "module",
"scripts": {
"test": "node test/governanceToken.test.mjs"
},
"devDependencies": {
"@openzeppelin/contracts": "^5.3.0",
"ethers": "^6.14.4",
"ganache": "^7.9.2",
"solc": "^0.8.30"
}
}

181 changes: 181 additions & 0 deletions solidity/test/governanceToken.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { ethers } from "ethers";
import ganache from "ganache";
import solc from "solc";

const require = createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const solidityRoot = path.resolve(__dirname, "..");
const repoRoot = path.resolve(solidityRoot, "..");

const phishingSource = `// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IGovernanceToken {
function delegateVote(address to) external;
}

contract PhishingDelegate {
IGovernanceToken public token;

constructor(address tokenAddress) {
token = IGovernanceToken(tokenAddress);
}

function attack(address to) external {
token.delegateVote(to);
}
}

contract ContractDelegator {
IGovernanceToken public token;

constructor(address tokenAddress) {
token = IGovernanceToken(tokenAddress);
}

function delegateTo(address to) external {
token.delegateVote(to);
}
}
`;

function resolveImport(importPath) {
if (importPath.startsWith("@openzeppelin/")) {
const resolved = require.resolve(importPath, { paths: [solidityRoot] });
return { contents: readFileSync(resolved, "utf8") };
}

const localPath = path.join(repoRoot, importPath);
try {
return { contents: readFileSync(localPath, "utf8") };
} catch (error) {
return { error: `Unable to resolve ${importPath}: ${error.message}` };
}
}

function compileContracts() {
const input = {
language: "Solidity",
sources: {
"solidity/contracts/GovernanceToken.sol": {
content: readFileSync(
path.join(solidityRoot, "contracts", "GovernanceToken.sol"),
"utf8",
),
},
"test/Delegators.sol": {
content: phishingSource,
},
},
settings: {
evmVersion: "paris",
outputSelection: {
"*": {
"*": ["abi", "evm.bytecode.object"],
},
},
},
};

const output = JSON.parse(solc.compile(JSON.stringify(input), { import: resolveImport }));
const errors = output.errors?.filter((error) => error.severity === "error") ?? [];
assert.deepEqual(errors, []);

return {
token: output.contracts["solidity/contracts/GovernanceToken.sol"].GovernanceToken,
phishing: output.contracts["test/Delegators.sol"].PhishingDelegate,
contractDelegator: output.contracts["test/Delegators.sol"].ContractDelegator,
};
}

async function deploy(contract, signer, args = []) {
const factory = new ethers.ContractFactory(
contract.abi,
`0x${contract.evm.bytecode.object}`,
signer,
);
const deployment = await factory.deploy(...args);
await deployment.waitForDeployment();
return deployment;
}

async function expectRevert(action) {
try {
await action();
} catch (error) {
assert.match(String(error.shortMessage ?? error.message), /revert/i);
return;
}
assert.fail("Expected revert");
}

const source = readFileSync(
path.join(solidityRoot, "contracts", "GovernanceToken.sol"),
"utf8",
);
assert.equal(source.includes("tx.origin"), false);

const contracts = compileContracts();
const ganacheProvider = ganache.provider({
chain: { chainId: 31_337 },
logging: { quiet: true },
wallet: { deterministic: true },
});
const provider = new ethers.BrowserProvider(ganacheProvider);
const owner = await provider.getSigner(0);
const victim = await provider.getSigner(1);
const delegatee = await provider.getSigner(2);
const attacker = await provider.getSigner(3);
const ownerAddress = await owner.getAddress();
const victimAddress = await victim.getAddress();
const delegateeAddress = await delegatee.getAddress();
const attackerAddress = await attacker.getAddress();

const token = await deploy(contracts.token, owner, [ethers.parseEther("1000")]);
await (await token.transfer(victimAddress, ethers.parseEther("100"))).wait();

const phishing = await deploy(contracts.phishing, attacker, [await token.getAddress()]);
await (await phishing.connect(victim).attack(attackerAddress)).wait();
assert.equal(await token.delegates(victimAddress), ethers.ZeroAddress);
assert.equal(await token.delegates(await phishing.getAddress()), attackerAddress);
assert.equal(await token.delegatedPower(attackerAddress), 0n);
assert.equal(await token.getVotingPower(attackerAddress), 0n);

await (await token.connect(victim).delegateVote(delegateeAddress)).wait();
assert.equal(await token.delegates(victimAddress), delegateeAddress);
assert.equal(await token.delegatedPower(delegateeAddress), ethers.parseEther("100"));

await (await token.connect(victim).transfer(attackerAddress, ethers.parseEther("40"))).wait();
assert.equal(await token.delegatedPower(delegateeAddress), ethers.parseEther("60"));

await (await token.connect(victim).revokeDelegate()).wait();
assert.equal(await token.delegates(victimAddress), ethers.ZeroAddress);
assert.equal(await token.delegatedPower(delegateeAddress), 0n);

const contractDelegator = await deploy(contracts.contractDelegator, owner, [
await token.getAddress(),
]);
await (await token.transfer(await contractDelegator.getAddress(), ethers.parseEther("25"))).wait();
await (await contractDelegator.delegateTo(delegateeAddress)).wait();
assert.equal(await token.delegatedPower(delegateeAddress), ethers.parseEther("25"));

await expectRevert(async () => {
const tx = await token.connect(victim).snapshot();
await tx.wait();
});
await (await token.connect(owner).snapshot()).wait();

const proposalId = await token.createProposal.staticCall("Ship safe delegation", 3600);
await (await token.createProposal("Ship safe delegation", 3600)).wait();
await (await token.connect(delegatee).vote(proposalId, true)).wait();
const proposal = await token.proposals(proposalId);
assert.equal(proposal.forVotes, await token.getVotingPower(delegateeAddress));
assert.equal(await token.owner(), ownerAddress);

console.log("GovernanceToken tx.origin phishing tests passed");
Loading