From 29348c44e9aea24ac6b4012369ee98f807d7c632 Mon Sep 17 00:00:00 2001 From: dotcom07 Date: Mon, 6 Apr 2026 04:56:14 +0000 Subject: [PATCH 1/4] feat: allow pre-start election metadata fixes --- src/interfaces/vestar/IVESTArElectionCore.sol | 19 ++++++-- src/vestar/election/VESTArElection.sol | 23 +++++++++ .../election/VESTArElectionLifecycle.t.sol | 48 +++++++++++++++---- 3 files changed, 77 insertions(+), 13 deletions(-) diff --git a/src/interfaces/vestar/IVESTArElectionCore.sol b/src/interfaces/vestar/IVESTArElectionCore.sol index ed0b380..51df909 100644 --- a/src/interfaces/vestar/IVESTArElectionCore.sol +++ b/src/interfaces/vestar/IVESTArElectionCore.sol @@ -19,13 +19,17 @@ interface IVESTArElectionCore is IVESTArPrivateVoteModule, IVESTArSettlementModule { - // 후보 등록 관련 코드 : Open 모드 validation에 쓰는 candidate allowlist 변경 로그 - event CandidateAllowlistUpdated( + // election 표시 메타데이터 관련 코드 : 제목 해시와 후보 manifest 위치/해시를 교체했을 때 남기는 로그 + event ElectionMetadataUpdated( bytes32 indexed electionId, - bytes32 indexed candidateHash, - bool allowed + bytes32 indexed titleHash, + bytes32 indexed candidateManifestHash, + string candidateManifestURI ); + // 후보 등록 관련 코드 : Open 모드 validation에 쓰는 candidate allowlist 변경 로그 + event CandidateAllowlistUpdated(bytes32 indexed electionId, bytes32 indexed candidateHash, bool allowed); + // Open / Private 모드 확인 function visibilityMode() external view returns (VESTArTypes.VisibilityMode); @@ -35,6 +39,13 @@ interface IVESTArElectionCore is // 다중 선택일 때 ballot 하나에 담을 수 있는 최대 후보 수 function maxSelectionsPerSubmission() external view returns (uint16); + // 메타데이터 수정 관련 코드 : 시작 전 organizer/admin이 제목/후보 manifest 오탈자를 수정 + function updateElectionMetadata( + bytes32 newTitleHash, + bytes32 newCandidateManifestHash, + string calldata newCandidateManifestURI + ) external; + // 후보 등록 관련 코드 : 투표 시작 전에 organizer/admin이 허용 후보 hash 목록을 세팅 function setCandidateAllowlist(bytes32[] calldata candidateHashes, bool allowed) external; diff --git a/src/vestar/election/VESTArElection.sol b/src/vestar/election/VESTArElection.sol index 87406b0..d34777e 100644 --- a/src/vestar/election/VESTArElection.sol +++ b/src/vestar/election/VESTArElection.sol @@ -66,6 +66,29 @@ contract VESTArElection is VESTArElectionCore { ); } + // 메타데이터 수정 관련 코드 : organizer/admin이 시작 전에 제목/후보 manifest 오탈자를 고칠 수 있게 함 + // 예시 : titleHash만 바뀌는 사소한 오탈자 수정이어도, 프론트/백엔드가 같은 스냅샷을 읽게 + // titleHash + candidateManifest(hash/URI)를 한 번에 갱신하고 이벤트로 남김 + function updateElectionMetadata( + bytes32 newTitleHash, + bytes32 newCandidateManifestHash, + string calldata newCandidateManifestURI + ) external { + _requirePlatformAdminOrOrganizer(); + require(syncState() == VESTArTypes.ElectionState.Scheduled, "VESTAr: already started"); + require(newTitleHash != bytes32(0), "VESTAr: title hash is zero"); + require(newCandidateManifestHash != bytes32(0), "VESTAr: candidate manifest hash is zero"); + require(bytes(newCandidateManifestURI).length > 0, "VESTAr: candidate manifest URI empty"); + + _config.titleHash = newTitleHash; + _config.candidateManifestHash = newCandidateManifestHash; + _config.candidateManifestURI = newCandidateManifestURI; + + emit ElectionMetadataUpdated( + _config.electionId, newTitleHash, newCandidateManifestHash, newCandidateManifestURI + ); + } + // 후보 등록 관련 코드 : organizer/admin이 투표 시작 전에 후보 hash allowlist를 세팅 // 예시 : "IU", "ParkHyoShin" 문자열 자체를 저장하지 않고 keccak256 hash 목록만 올려서 // 프론트/백엔드는 manifest와 같은 hash 규칙을 써서 허용 후보인지 맞춰 볼 수 있음 diff --git a/test/vestar/election/VESTArElectionLifecycle.t.sol b/test/vestar/election/VESTArElectionLifecycle.t.sol index a4bcd19..ca1e21e 100644 --- a/test/vestar/election/VESTArElectionLifecycle.t.sol +++ b/test/vestar/election/VESTArElectionLifecycle.t.sol @@ -17,9 +17,7 @@ contract VESTArElectionLifecycleTest is VESTArTestBase { function testScheduledToActiveStateTransitionFollowsTime() public { VESTArTypes.ElectionConfig memory config = _buildOpenConfig( - bytes32("open-lifecycle"), - uint64(block.timestamp + 1 days), - uint64(block.timestamp + 3 days) + bytes32("open-lifecycle"), uint64(block.timestamp + 1 days), uint64(block.timestamp + 3 days) ); election.initialize(config, organizer, false, address(mockKarmaRegistry), platformAdmin, platformTreasury); @@ -34,9 +32,7 @@ contract VESTArElectionLifecycleTest is VESTArTestBase { function testOrganizerCanCancelBeforeStart() public { VESTArTypes.ElectionConfig memory config = _buildOpenConfig( - bytes32("cancel-before-start"), - uint64(block.timestamp + 1 days), - uint64(block.timestamp + 2 days) + bytes32("cancel-before-start"), uint64(block.timestamp + 1 days), uint64(block.timestamp + 2 days) ); election.initialize(config, organizer, false, address(mockKarmaRegistry), platformAdmin, platformTreasury); @@ -138,13 +134,47 @@ contract VESTArElectionLifecycleTest is VESTArTestBase { ); } + function testOrganizerCanUpdateElectionMetadataBeforeStart() public { + VESTArTypes.ElectionConfig memory config = _buildOpenConfig( + bytes32("metadata-edit"), uint64(block.timestamp + 1 days), uint64(block.timestamp + 3 days) + ); + + election.initialize(config, organizer, false, address(mockKarmaRegistry), platformAdmin, platformTreasury); + + bytes32 newTitleHash = keccak256("Lifecycle Open Vote Fixed"); + bytes32 newCandidateManifestHash = keccak256("candidates-fixed"); + string memory newCandidateManifestURI = "ipfs://candidates-fixed"; + + vm.prank(organizer); + election.updateElectionMetadata(newTitleHash, newCandidateManifestHash, newCandidateManifestURI); + + VESTArTypes.ElectionConfig memory updatedConfig = election.getElectionConfig(); + assertEq(updatedConfig.titleHash, newTitleHash); + assertEq(updatedConfig.candidateManifestHash, newCandidateManifestHash); + assertEq(updatedConfig.candidateManifestURI, newCandidateManifestURI); + } + + function testOrganizerCannotUpdateElectionMetadataAfterStart() public { + VESTArTypes.ElectionConfig memory config = _buildOpenConfig( + bytes32("metadata-lock"), uint64(block.timestamp + 1 days), uint64(block.timestamp + 3 days) + ); + + election.initialize(config, organizer, false, address(mockKarmaRegistry), platformAdmin, platformTreasury); + + vm.warp(block.timestamp + 1 days); + + vm.prank(organizer); + vm.expectRevert("VESTAr: already started"); + election.updateElectionMetadata( + keccak256("Lifecycle Open Vote Fixed"), keccak256("candidates-fixed"), "ipfs://candidates-fixed" + ); + } + function testOrganizerCannotEditCandidateAllowlistAfterStart() public { // 실제 사례 : organizer는 시작 전까지만 후보 목록을 확정하고, // 투표가 열린 뒤에는 프론트/백 기준점이 흔들리지 않도록 수정이 막혀야 함 VESTArTypes.ElectionConfig memory config = _buildOpenConfig( - bytes32("allowlist-lock"), - uint64(block.timestamp + 1 days), - uint64(block.timestamp + 3 days) + bytes32("allowlist-lock"), uint64(block.timestamp + 1 days), uint64(block.timestamp + 3 days) ); election.initialize(config, organizer, false, address(mockKarmaRegistry), platformAdmin, platformTreasury); From 1b4bf18e1a82e8b0c0f42fb6dc7e3c3f20a7f4ea Mon Sep 17 00:00:00 2001 From: dotcom07 Date: Tue, 7 Apr 2026 05:30:37 +0000 Subject: [PATCH 2/4] fix: wire Status Karma defaults into deploy script --- script/DeployVESTArStack.s.sol | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/script/DeployVESTArStack.s.sol b/script/DeployVESTArStack.s.sol index dd9804d..8edc6c7 100644 --- a/script/DeployVESTArStack.s.sol +++ b/script/DeployVESTArStack.s.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.24; import {Script, console2} from "forge-std/Script.sol"; import {MockUSDT} from "../src/mocks/MockUSDT.sol"; +import {StatusTestnetConfig} from "../src/config/StatusTestnetConfig.sol"; import {VESTArElection} from "../src/vestar/election/VESTArElection.sol"; import {VESTArElectionFactory} from "../src/vestar/factory/VESTArElectionFactory.sol"; import {VESTArKarmaRegistry} from "../src/vestar/registry/VESTArKarmaRegistry.sol"; @@ -34,11 +35,11 @@ contract DeployVESTArStackScript is Script { // 환경변수 관련 코드 : // PLATFORM_TREASURY를 따로 안 넣으면 배포자 주소를 임시 treasury로 사용 - // Status Karma 주소를 아직 모르면 0 주소로 시작하고, 나중에 owner가 registry를 갱신 가능 + // Status Karma 원본 주소는 env로 덮어쓸 수 있고, 없으면 testnet 기본값을 사용 address initialOwner = vm.envOr("INITIAL_OWNER", deployer); address platformTreasury = vm.envOr("PLATFORM_TREASURY", deployer); - address statusKarma = vm.envOr("STATUS_KARMA", address(0)); - address statusKarmaTiers = vm.envOr("STATUS_KARMA_TIERS", address(0)); + address statusKarma = vm.envOr("STATUS_KARMA", StatusTestnetConfig.KARMA); + address statusKarmaTiers = vm.envOr("STATUS_KARMA_TIERS", StatusTestnetConfig.KARMA_TIERS); bool deployMockUsdt = vm.envOr("DEPLOY_MOCK_USDT", true); vm.startBroadcast(privateKey); From 53c7cd7cb314440d0c3a658049930e1ff8c4740d Mon Sep 17 00:00:00 2001 From: dotcom07 Date: Wed, 8 Apr 2026 04:10:14 +0000 Subject: [PATCH 3/4] feat: add cancellation metadata and voter-claim refund flow --- abi/VESTArElection.json | 122 ++++++++++++++++++ .../vestar/IVESTArElectionLifecycle.sol | 13 ++ .../vestar/IVESTArSettlementModule.sol | 29 +++++ src/libraries/vestar/VESTArTypes.sol | 26 ++++ src/vestar/election/VESTArElection.sol | 1 + .../election/base/VESTArElectionStorage.sol | 4 + .../modules/VESTArElectionLifecycleImpl.sol | 28 +++- .../modules/VESTArSettlementModuleImpl.sol | 54 ++++++++ .../election/VESTArElectionLifecycle.t.sol | 72 +++++++++++ test/vestar/election/VESTArOpenVote.t.sol | 5 + test/vestar/election/VESTArPrivateVote.t.sol | 5 + test/vestar/election/VESTArSettlement.t.sol | 117 +++++++++++++++++ 12 files changed, 473 insertions(+), 3 deletions(-) diff --git a/abi/VESTArElection.json b/abi/VESTArElection.json index 98ef301..336428f 100644 --- a/abi/VESTArElection.json +++ b/abi/VESTArElection.json @@ -113,6 +113,13 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "cancelElection", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "closeElection", @@ -231,6 +238,36 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "getCancellationSummary", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct VESTArTypes.CancellationSummary", + "components": [ + { + "name": "cancelledBy", + "type": "address", + "internalType": "address" + }, + { + "name": "cancelledAt", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "previousState", + "type": "uint8", + "internalType": "enum VESTArTypes.ElectionState" + } + ] + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "getElectionConfig", @@ -1114,6 +1151,29 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "updateElectionMetadata", + "inputs": [ + { + "name": "newTitleHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "newCandidateManifestHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "newCandidateManifestURI", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "visibilityMode", @@ -1152,6 +1212,37 @@ ], "anonymous": false }, + { + "type": "event", + "name": "ElectionCancelled", + "inputs": [ + { + "name": "electionId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "cancelledBy", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "previousState", + "type": "uint8", + "indexed": false, + "internalType": "enum VESTArTypes.ElectionState" + }, + { + "name": "cancelledAt", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, { "type": "event", "name": "ElectionInitialized", @@ -1201,6 +1292,37 @@ ], "anonymous": false }, + { + "type": "event", + "name": "ElectionMetadataUpdated", + "inputs": [ + { + "name": "electionId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "titleHash", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "candidateManifestHash", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "candidateManifestURI", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, { "type": "event", "name": "ElectionStateUpdated", diff --git a/src/interfaces/vestar/IVESTArElectionLifecycle.sol b/src/interfaces/vestar/IVESTArElectionLifecycle.sol index 61b9cc0..acc8ff2 100644 --- a/src/interfaces/vestar/IVESTArElectionLifecycle.sol +++ b/src/interfaces/vestar/IVESTArElectionLifecycle.sol @@ -34,6 +34,14 @@ interface IVESTArElectionLifecycle { // 내부 팀 관리자에게 key reveal 권한을 위임했는지 기록 event RevealManagerUpdated(address indexed manager, bool allowed); + // 취소가 확정됐을 때 누가 언제 어떤 상태에서 취소했는지 기록 + event ElectionCancelled( + bytes32 indexed electionId, + address indexed cancelledBy, + VESTArTypes.ElectionState previousState, + uint64 cancelledAt + ); + // 최종 결과 manifest가 확정됐을 때 남기는 이벤트 event ResultFinalized( bytes32 indexed electionId, @@ -65,6 +73,9 @@ interface IVESTArElectionLifecycle { // 결과 요약도 struct 단위로 반환 function getResultSummary() external view returns (VESTArTypes.ResultSummary memory); + // 취소 정보도 struct 단위로 반환 + function getCancellationSummary() external view returns (VESTArTypes.CancellationSummary memory); + // returns (...)가 붙은 non-view 함수도 가능하며, 상태를 바꾸고 새 상태를 반환할 수 있음 function syncState() external returns (VESTArTypes.ElectionState); @@ -74,6 +85,8 @@ interface IVESTArElectionLifecycle { // 플랫폼 owner/admin이 내부 팀 관리자에게 key reveal 권한을 위임 function setRevealManager(address manager, bool allowed) external; + function cancelElection() external; + function cancelBeforeStart() external; function closeElection() external; diff --git a/src/interfaces/vestar/IVESTArSettlementModule.sol b/src/interfaces/vestar/IVESTArSettlementModule.sol index 17af61b..fb9fd79 100644 --- a/src/interfaces/vestar/IVESTArSettlementModule.sol +++ b/src/interfaces/vestar/IVESTArSettlementModule.sol @@ -16,6 +16,20 @@ interface IVESTArSettlementModule { uint256 organizerRevenueAmount ); + // organizer/admin이 "이 election은 정산 대신 각 유저가 직접 환불받게 한다"를 활성화할 때 남기는 이벤트 + event RefundsEnabled( + bytes32 indexed electionId, + address indexed enabledBy, + uint256 totalRefundableAmount + ); + + // 유저가 자기 지갑으로 직접 환불을 수령했을 때 남기는 이벤트 + event RefundClaimed( + bytes32 indexed electionId, + address indexed voter, + uint256 refundAmount + ); + // FREE / PAID 모드를 enum으로 읽음 function paymentMode() external view returns (VESTArTypes.PaymentMode); @@ -43,6 +57,21 @@ interface IVESTArSettlementModule { // 정산 요약 전체를 struct로 조회 function getSettlementSummary() external view returns (VESTArTypes.SettlementSummary memory); + // 환불 요약 전체를 struct로 조회 + function getRefundSummary() external view returns (VESTArTypes.RefundSummary memory); + + // 특정 유저가 현재 claim 가능한 누적 환불 금액 + function refundableAmountOf(address voter) external view returns (uint256); + + // refund mode가 이미 열렸는지 빠르게 읽는 helper + function refundsEnabled() external view returns (bool); + + // organizer/admin이 정산 대신 유저 개별 claim 환불 모드를 연다 + function enableRefunds() external; + + // 유저가 자기 누적 환불액을 직접 수령 + function claimRefund() external returns (uint256); + // 투표 종료 후 50:50 정산 실행 function settleRevenue() external; } diff --git a/src/libraries/vestar/VESTArTypes.sol b/src/libraries/vestar/VESTArTypes.sol index eb20310..4a84753 100644 --- a/src/libraries/vestar/VESTArTypes.sol +++ b/src/libraries/vestar/VESTArTypes.sol @@ -111,6 +111,16 @@ library VESTArTypes { uint256 totalInvalidVotes; } + // 취소 메타데이터 관련 코드 : 누가 언제 어떤 상태에서 취소했는지 프론트/백엔드가 한 번에 읽기 위한 구조체 + struct CancellationSummary { + // 취소를 실행한 organizer 또는 platform admin 주소 + address cancelledBy; + // 취소가 확정된 시각 + uint64 cancelledAt; + // 취소 직전 live state + ElectionState previousState; + } + // 투표권 사용량 관련 코드 : 한 유저가 특정 단위 기간에서 ballot을 몇 번 썼는지 표현 struct BallotUsage { // periodKey: 단위 기간 구분용 키 @@ -138,4 +148,20 @@ library VESTArTypes { // 이미 정산을 실행했는지 여부 bool settled; } + + // 환불 기능 관련 코드 : 주최자/admin이 환불 모드를 열었는지와 누적 환불 진행 상황을 프론트가 한 번에 읽기 위한 구조체 + struct RefundSummary { + // 결제 토큰 주소 + address paymentToken; + // 환불 모드가 열릴 때 스냅샷한 총 환불 대상 금액 + uint256 totalRefundableAmount; + // 이미 사용자들이 claim한 환불 누적액 + uint256 totalRefundedAmount; + // 환불 모드를 연 시각 + uint64 refundsEnabledAt; + // 환불 모드를 연 organizer 또는 platform admin 주소 + address refundsEnabledBy; + // 환불 모드가 활성화됐는지 여부 + bool refundsEnabled; + } } diff --git a/src/vestar/election/VESTArElection.sol b/src/vestar/election/VESTArElection.sol index d34777e..97ae9d6 100644 --- a/src/vestar/election/VESTArElection.sol +++ b/src/vestar/election/VESTArElection.sol @@ -46,6 +46,7 @@ contract VESTArElection is VESTArElectionCore { _platformAdmin = platformAdminAddress; _settlementSummary.paymentToken = config.paymentToken; _settlementSummary.platformTreasury = platformTreasuryAddress; + _refundSummary.paymentToken = config.paymentToken; _state = VESTArTypes.ElectionState.Scheduled; owner = platformAdminAddress; diff --git a/src/vestar/election/base/VESTArElectionStorage.sol b/src/vestar/election/base/VESTArElectionStorage.sol index 194e22e..0d8b268 100644 --- a/src/vestar/election/base/VESTArElectionStorage.sol +++ b/src/vestar/election/base/VESTArElectionStorage.sol @@ -21,7 +21,9 @@ abstract contract VESTArElectionStorage { VESTArTypes.ElectionConfig internal _config; VESTArTypes.ResultSummary internal _resultSummary; + VESTArTypes.CancellationSummary internal _cancellationSummary; VESTArTypes.SettlementSummary internal _settlementSummary; + VESTArTypes.RefundSummary internal _refundSummary; VESTArTypes.ElectionState internal _state; @@ -39,6 +41,7 @@ abstract contract VESTArElectionStorage { mapping(bytes32 => uint256) internal _openVoteCountByCandidateHash; mapping(bytes32 => bool) internal _allowedCandidateHash; mapping(address => bool) internal _revealManagers; + mapping(address => uint256) internal _refundableAmountByVoter; // 설정 검증 관련 코드 : 새 정책에 맞지 않는 election config를 초기 단계에서 막기 위한 내부 helper function _validateElectionConfig() internal view { @@ -216,6 +219,7 @@ abstract contract VESTArElectionStorage { IERC20(_config.paymentToken).safeTransferFrom(payer, address(this), paymentAmount); _totalCollectedAmount += paymentAmount; + _refundableAmountByVoter[payer] += paymentAmount; } // 결제 정책 관련 코드 : 50:50이지만 홀수 1단위 잔차는 organizer에게 귀속 diff --git a/src/vestar/election/modules/VESTArElectionLifecycleImpl.sol b/src/vestar/election/modules/VESTArElectionLifecycleImpl.sol index 274a245..80027bf 100644 --- a/src/vestar/election/modules/VESTArElectionLifecycleImpl.sol +++ b/src/vestar/election/modules/VESTArElectionLifecycleImpl.sol @@ -48,6 +48,11 @@ abstract contract VESTArElectionLifecycleImpl is VESTArElectionStorage, IVESTArE return _resultSummary; } + // 취소 메타데이터 관련 코드 : 프론트/백엔드가 cancellation actor / time / 직전 상태를 한 번에 읽을 때 사용 + function getCancellationSummary() public view virtual returns (VESTArTypes.CancellationSummary memory) { + return _cancellationSummary; + } + // key reveal 권한 관련 코드 : platform admin 또는 위임된 내부 팀 관리자만 true function isRevealManager(address account) public view virtual returns (bool) { return _isRevealManager(account); @@ -74,13 +79,30 @@ abstract contract VESTArElectionLifecycleImpl is VESTArElectionStorage, IVESTArE return _state; } - // 투표 상태 관련 코드 : 시작 전이라면 cancel 가능 - function cancelBeforeStart() public virtual { + // 투표 상태 관련 코드 : organizer 또는 platform admin이 Finalized 전이라면 언제든 취소 가능 + // 예시 : 진행 중인 선거에서 운영 이슈가 발견되면 즉시 Cancelled로 전환하고, 이후에는 되돌리지 않음 + function cancelElection() public virtual { _requirePlatformAdminOrOrganizer(); - require(syncState() == VESTArTypes.ElectionState.Scheduled, "VESTAr: already started"); + + VESTArTypes.ElectionState currentState = syncState(); + require(currentState != VESTArTypes.ElectionState.Cancelled, "VESTAr: already cancelled"); + require(currentState != VESTArTypes.ElectionState.Finalized, "VESTAr: already finalized"); + + _cancellationSummary = VESTArTypes.CancellationSummary({ + cancelledBy: msg.sender, + cancelledAt: uint64(block.timestamp), + previousState: currentState + }); + + emit ElectionCancelled(_config.electionId, msg.sender, currentState, uint64(block.timestamp)); _transitionState(VESTArTypes.ElectionState.Cancelled); } + // 하위호환 관련 코드 : 예전 프론트/백엔드가 쓰던 함수명도 동일 의미로 유지 + function cancelBeforeStart() public virtual { + cancelElection(); + } + // 투표 상태 관련 코드 : organizer 또는 platform admin이 Active 상태를 강제로 마감 function closeElection() public virtual { _requirePlatformAdminOrOrganizer(); diff --git a/src/vestar/election/modules/VESTArSettlementModuleImpl.sol b/src/vestar/election/modules/VESTArSettlementModuleImpl.sol index c8fc6ce..cd85f47 100644 --- a/src/vestar/election/modules/VESTArSettlementModuleImpl.sol +++ b/src/vestar/election/modules/VESTArSettlementModuleImpl.sol @@ -56,6 +56,59 @@ abstract contract VESTArSettlementModuleImpl is VESTArElectionStorage, IVESTArSe return _settlementSummary; } + function getRefundSummary() public view virtual returns (VESTArTypes.RefundSummary memory) { + return _refundSummary; + } + + function refundableAmountOf(address voter) public view virtual returns (uint256) { + if (!_refundSummary.refundsEnabled) { + return 0; + } + + return _refundableAmountByVoter[voter]; + } + + function refundsEnabled() public view virtual returns (bool) { + return _refundSummary.refundsEnabled; + } + + function enableRefunds() public virtual { + _validateElectionConfig(); + _syncStateFromClock(); + _requirePlatformAdminOrOrganizer(); + + require(_config.paymentMode == VESTArTypes.PaymentMode.PAID, "VESTAr: free election"); + require(_state != VESTArTypes.ElectionState.Scheduled, "VESTAr: not started"); + require(_state != VESTArTypes.ElectionState.Active, "VESTAr: still active"); + require(_totalCollectedAmount > 0, "VESTAr: no payments"); + require(!_settlementSummary.settled, "VESTAr: already settled"); + require(!_refundSummary.refundsEnabled, "VESTAr: refunds already enabled"); + + _refundSummary.paymentToken = _config.paymentToken; + _refundSummary.totalRefundableAmount = _totalCollectedAmount; + _refundSummary.totalRefundedAmount = 0; + _refundSummary.refundsEnabledAt = uint64(block.timestamp); + _refundSummary.refundsEnabledBy = msg.sender; + _refundSummary.refundsEnabled = true; + + emit RefundsEnabled(_config.electionId, msg.sender, _refundSummary.totalRefundableAmount); + } + + function claimRefund() public virtual returns (uint256 refundAmount) { + _validateElectionConfig(); + require(_refundSummary.refundsEnabled, "VESTAr: refunds disabled"); + + refundAmount = _refundableAmountByVoter[msg.sender]; + require(refundAmount > 0, "VESTAr: no refundable amount"); + + _refundableAmountByVoter[msg.sender] = 0; + _refundSummary.totalRefundedAmount += refundAmount; + + IERC20(_config.paymentToken).safeTransfer(msg.sender, refundAmount); + + emit RefundClaimed(_config.electionId, msg.sender, refundAmount); + } + // 정산 관련 코드 : Finalized 이후 한 번만 실행하고, organizer가 홀수 잔차를 가져가도록 실제 송금까지 처리 function settleRevenue() public virtual { _validateElectionConfig(); @@ -64,6 +117,7 @@ abstract contract VESTArSettlementModuleImpl is VESTArElectionStorage, IVESTArSe require(_state == VESTArTypes.ElectionState.Finalized, "VESTAr: not finalized"); require(!_settlementSummary.settled, "VESTAr: already settled"); + require(!_refundSummary.refundsEnabled, "VESTAr: refunds enabled"); (uint256 platformRevenueAmount, uint256 organizerRevenueAmount) = _previewSettlementSplit(_totalCollectedAmount); diff --git a/test/vestar/election/VESTArElectionLifecycle.t.sol b/test/vestar/election/VESTArElectionLifecycle.t.sol index ca1e21e..71862b6 100644 --- a/test/vestar/election/VESTArElectionLifecycle.t.sol +++ b/test/vestar/election/VESTArElectionLifecycle.t.sol @@ -43,6 +43,78 @@ contract VESTArElectionLifecycleTest is VESTArTestBase { assertEq(uint256(election.state()), uint256(VESTArTypes.ElectionState.Cancelled)); } + function testOrganizerCanCancelOpenElectionAfterItStarts() public { + VESTArTypes.ElectionConfig memory config = _buildOpenConfig( + bytes32("cancel-active-open"), uint64(block.timestamp + 1 days), uint64(block.timestamp + 2 days) + ); + + election.initialize(config, organizer, false, address(mockKarmaRegistry), platformAdmin, platformTreasury); + + vm.warp(block.timestamp + 1 days + 1); + + vm.prank(organizer); + election.cancelElection(); + + VESTArTypes.CancellationSummary memory cancellationSummary = election.getCancellationSummary(); + + assertEq(uint256(election.state()), uint256(VESTArTypes.ElectionState.Cancelled)); + assertEq(cancellationSummary.cancelledBy, organizer); + assertEq(uint256(cancellationSummary.previousState), uint256(VESTArTypes.ElectionState.Active)); + assertEq(cancellationSummary.cancelledAt, uint64(block.timestamp)); + } + + function testPlatformAdminCanCancelPrivateElectionAfterKeyReveal() public { + vm.warp(10 days); + + bytes memory privateKeyData = hex"1234abcd"; + VESTArTypes.ElectionConfig memory config = _buildPrivateConfig( + bytes32("cancel-key-revealed"), + uint64(block.timestamp - 2 days), + uint64(block.timestamp - 1 days), + uint64(block.timestamp - 1 hours), + keccak256(privateKeyData) + ); + + election.initialize(config, organizer, true, address(mockKarmaRegistry), platformAdmin, platformTreasury); + + vm.prank(platformAdmin); + election.revealPrivateKey(privateKeyData); + + vm.prank(platformAdmin); + election.cancelElection(); + + VESTArTypes.CancellationSummary memory cancellationSummary = election.getCancellationSummary(); + + assertEq(uint256(election.state()), uint256(VESTArTypes.ElectionState.Cancelled)); + assertEq(cancellationSummary.cancelledBy, platformAdmin); + assertEq(uint256(cancellationSummary.previousState), uint256(VESTArTypes.ElectionState.KeyRevealed)); + } + + function testCannotCancelElectionAfterFinalize() public { + vm.warp(10 days); + + VESTArTypes.ElectionConfig memory config = _buildOpenConfig( + bytes32("cancel-after-finalize"), uint64(block.timestamp - 3 days), uint64(block.timestamp - 1 days) + ); + + election.initialize(config, organizer, false, address(mockKarmaRegistry), platformAdmin, platformTreasury); + + vm.prank(organizer); + election.finalizeResults( + VESTArTypes.ResultSummary({ + resultManifestHash: keccak256("open-results"), + resultManifestURI: "ipfs://open-results", + totalSubmissions: 3, + totalValidVotes: 3, + totalInvalidVotes: 0 + }) + ); + + vm.prank(organizer); + vm.expectRevert("VESTAr: already finalized"); + election.cancelElection(); + } + function testRevealManagerCanRevealPrivateKeyAfterRevealTime() public { vm.warp(10 days); diff --git a/test/vestar/election/VESTArOpenVote.t.sol b/test/vestar/election/VESTArOpenVote.t.sol index eb2bbba..06dbc84 100644 --- a/test/vestar/election/VESTArOpenVote.t.sol +++ b/test/vestar/election/VESTArOpenVote.t.sol @@ -45,6 +45,10 @@ contract VESTArOpenVoteHarness is VESTArOpenVoteModuleImpl { function totalCollectedAmount() external view returns (uint256) { return _totalCollectedAmount; } + + function trackedRefundableAmount(address voterAddress) external view returns (uint256) { + return _refundableAmountByVoter[voterAddress]; + } } // OpenVote 모듈 테스트 자리 @@ -90,6 +94,7 @@ contract VESTArOpenVoteTest is VESTArTestBase { assertEq(openVoteHarness.submittedBallots(voter, 0), 1); assertEq(openVoteHarness.totalCollectedAmount(), FULL_PRICE_PER_BALLOT); + assertEq(openVoteHarness.trackedRefundableAmount(voter), FULL_PRICE_PER_BALLOT); assertEq(openVoteHarness.totalVotesForCandidate("IU"), 1); assertEq(openVoteHarness.totalVotesForCandidate("ParkHyoShin"), 1); assertEq(openVoteHarness.totalVotesForCandidate("Naul"), 1); diff --git a/test/vestar/election/VESTArPrivateVote.t.sol b/test/vestar/election/VESTArPrivateVote.t.sol index 0d70651..492e5da 100644 --- a/test/vestar/election/VESTArPrivateVote.t.sol +++ b/test/vestar/election/VESTArPrivateVote.t.sol @@ -54,6 +54,10 @@ contract VESTArPrivateVoteModuleHarness is VESTArPrivateVoteModuleImpl { function totalCollectedAmount() external view returns (uint256) { return _totalCollectedAmount; } + + function trackedRefundableAmount(address voterAddress) external view returns (uint256) { + return _refundableAmountByVoter[voterAddress]; + } } contract VESTArPrivateVoteTest is VESTArTestBase { @@ -161,6 +165,7 @@ contract VESTArPrivateVoteTest is VESTArTestBase { assertEq(privateVoteHarness.submittedBallots(voter, 0), 1); assertEq(privateVoteHarness.totalCollectedAmount(), 25_000); + assertEq(privateVoteHarness.trackedRefundableAmount(voter), 25_000); } function testSubmitEncryptedVoteEmitsBackendFriendlyHashEvent() public { diff --git a/test/vestar/election/VESTArSettlement.t.sol b/test/vestar/election/VESTArSettlement.t.sol index 0a22530..174ff54 100644 --- a/test/vestar/election/VESTArSettlement.t.sol +++ b/test/vestar/election/VESTArSettlement.t.sol @@ -40,6 +40,14 @@ contract VESTArSettlementModuleHarness is VESTArSettlementModuleImpl { _settlementSummary = newSettlementSummary; } + function setRefundSummary(VESTArTypes.RefundSummary memory newRefundSummary) external { + _refundSummary = newRefundSummary; + } + + function setRefundableAmount(address voterAddress, uint256 newRefundableAmount) external { + _refundableAmountByVoter[voterAddress] = newRefundableAmount; + } + // internal helper를 external로 감싸면 계산 규칙을 isolate해서 테스트 가능 function previewSettlementSplit(uint256 totalRevenueAmount) external @@ -218,6 +226,115 @@ contract VESTArSettlementTest is VESTArTestBase { assertTrue(settlementHarness.getSettlementSummary().settled); } + function testEnableRefundsStoresSnapshotForClaimBasedRefundFlow() public { + settlementHarness.configureSettlement( + VESTArTypes.PaymentMode.PAID, + 25_000, + address(mockUSDT), + platformTreasury, + organizer, + platformAdmin + ); + settlementHarness.setElectionState(VESTArTypes.ElectionState.Closed); + settlementHarness.setTotalCollectedAmount(200); + + vm.prank(organizer); + settlementHarness.enableRefunds(); + + VESTArTypes.RefundSummary memory refundSummary = settlementHarness.getRefundSummary(); + assertEq(refundSummary.paymentToken, address(mockUSDT)); + assertEq(refundSummary.totalRefundableAmount, 200); + assertEq(refundSummary.totalRefundedAmount, 0); + assertEq(refundSummary.refundsEnabledBy, organizer); + assertTrue(refundSummary.refundsEnabled); + assertTrue(settlementHarness.refundsEnabled()); + } + + function testClaimRefundLetsVoterPullOwnPaidAmount() public { + settlementHarness.configureSettlement( + VESTArTypes.PaymentMode.PAID, + 25_000, + address(mockUSDT), + platformTreasury, + organizer, + platformAdmin + ); + settlementHarness.setElectionState(VESTArTypes.ElectionState.Closed); + settlementHarness.setTotalCollectedAmount(200); + settlementHarness.setRefundableAmount(voter, 120); + settlementHarness.setRefundableAmount(revealManager, 80); + mockUSDT.mint(address(settlementHarness), 200); + + vm.prank(platformAdmin); + settlementHarness.enableRefunds(); + + assertEq(settlementHarness.refundableAmountOf(voter), 120); + + vm.prank(voter); + uint256 refundedAmount = settlementHarness.claimRefund(); + + assertEq(refundedAmount, 120); + assertEq(mockUSDT.balanceOf(voter), 120); + assertEq(settlementHarness.refundableAmountOf(voter), 0); + assertEq(settlementHarness.refundableAmountOf(revealManager), 80); + assertEq(settlementHarness.getRefundSummary().totalRefundedAmount, 120); + } + + function testClaimRefundRevertsWhenRefundModeIsDisabled() public { + settlementHarness.configureSettlement( + VESTArTypes.PaymentMode.PAID, + 25_000, + address(mockUSDT), + platformTreasury, + organizer, + platformAdmin + ); + settlementHarness.setRefundableAmount(voter, 25_000); + + vm.prank(voter); + vm.expectRevert("VESTAr: refunds disabled"); + settlementHarness.claimRefund(); + } + + function testEnableRefundsBlocksLaterSettlement() public { + settlementHarness.configureSettlement( + VESTArTypes.PaymentMode.PAID, + 25_000, + address(mockUSDT), + platformTreasury, + organizer, + platformAdmin + ); + settlementHarness.setElectionState(VESTArTypes.ElectionState.Finalized); + settlementHarness.setTotalCollectedAmount(200); + + vm.prank(platformAdmin); + settlementHarness.enableRefunds(); + + mockUSDT.mint(address(settlementHarness), 200); + + vm.prank(platformAdmin); + vm.expectRevert("VESTAr: refunds enabled"); + settlementHarness.settleRevenue(); + } + + function testRandomUserCannotEnableRefunds() public { + settlementHarness.configureSettlement( + VESTArTypes.PaymentMode.PAID, + 25_000, + address(mockUSDT), + platformTreasury, + organizer, + platformAdmin + ); + settlementHarness.setElectionState(VESTArTypes.ElectionState.Closed); + settlementHarness.setTotalCollectedAmount(100); + + vm.prank(voter); + vm.expectRevert("VESTAr: only admin or organizer"); + settlementHarness.enableRefunds(); + } + function testRandomUserCannotSettleRevenue() public { // 실제 사례 : 유저는 결과를 구경할 수는 있어도, organizer/플랫폼 대신 정산 버튼을 누를 수는 없어야 함 settlementHarness.configureSettlement( From 89957bbd0c32e705152305702b7d55926e91000a Mon Sep 17 00:00:00 2001 From: dotcom07 Date: Wed, 8 Apr 2026 04:18:21 +0000 Subject: [PATCH 4/4] chore: refresh status testnet deployment artifacts --- abi/VESTArElection.json | 147 ++++++++++++++++++++++++++++++ abi/status-testnet.addresses.json | 10 +- 2 files changed, 152 insertions(+), 5 deletions(-) diff --git a/abi/VESTArElection.json b/abi/VESTArElection.json index 336428f..0fba8f8 100644 --- a/abi/VESTArElection.json +++ b/abi/VESTArElection.json @@ -120,6 +120,19 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "claimRefund", + "inputs": [], + "outputs": [ + { + "name": "refundAmount", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "closeElection", @@ -185,6 +198,13 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "enableRefunds", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "factory", @@ -388,6 +408,51 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "getRefundSummary", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct VESTArTypes.RefundSummary", + "components": [ + { + "name": "paymentToken", + "type": "address", + "internalType": "address" + }, + { + "name": "totalRefundableAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "totalRefundedAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "refundsEnabledAt", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "refundsEnabledBy", + "type": "address", + "internalType": "address" + }, + { + "name": "refundsEnabled", + "type": "bool", + "internalType": "bool" + } + ] + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "getResultSummary", @@ -928,6 +993,38 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "refundableAmountOf", + "inputs": [ + { + "name": "voter", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "refundsEnabled", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "remainingBallots", @@ -1485,6 +1582,56 @@ ], "anonymous": false }, + { + "type": "event", + "name": "RefundClaimed", + "inputs": [ + { + "name": "electionId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "voter", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "refundAmount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RefundsEnabled", + "inputs": [ + { + "name": "electionId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "enabledBy", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "totalRefundableAmount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, { "type": "event", "name": "ResultFinalized", diff --git a/abi/status-testnet.addresses.json b/abi/status-testnet.addresses.json index 2487f28..3745d6e 100644 --- a/abi/status-testnet.addresses.json +++ b/abi/status-testnet.addresses.json @@ -2,9 +2,9 @@ "chainName": "Status Network Testnet", "chainId": 1660990954, "rpcUrl": "https://public.sepolia.rpc.status.network", - "OrganizerRegistry": "0x697a820eCBbf029184A80fe8A2Fa20c0A80F9A11", - "KarmaRegistry": "0x447cBF1e81157b68fF21405240d40B63cfE08417", - "ElectionImplementation": "0xa5e0b4f1C45418Ef4F266a4acF114734F2664693", - "VESTArElectionFactory": "0x0dd90Bc92436aafc280E94e94a51AbF068851f4C", - "MockUSDT": "0xd77B3f72259c92815380a545D29a60780AC90036" + "OrganizerRegistry": "0x6b6F30c54F0a382328409941A8eB824D02EFCD8f", + "KarmaRegistry": "0x9880c7Ff7Be22A1B2e469E594157eE6C14604472", + "ElectionImplementation": "0x93ec16287D1da13b4af58f3F0B9D717cae8b4A8A", + "VESTArElectionFactory": "0xE9e5AE2892542fd736b5391f0a722C641DC1fEDe", + "MockUSDT": "0x3a91A1b0bF95eE4e3F4e2F3F2A386995D884ee1d" }