From 1a87cb6b18b45b9f04c6f93f58bb6ca21d17760e Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Wed, 23 Oct 2024 14:50:02 -0700 Subject: [PATCH 01/11] fix: add ccip-025 implementation draft Linked to citycoins/governance#47 and citycoins/governance#48 --- Clarinet.toml | 5 + .../ccip025-extend-sunset-period-3.clar | 276 ++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 contracts/proposals/ccip025-extend-sunset-period-3.clar diff --git a/Clarinet.toml b/Clarinet.toml index 67ce4f0..50fa3cc 100644 --- a/Clarinet.toml +++ b/Clarinet.toml @@ -201,6 +201,11 @@ path = "contracts/proposals/ccip019-pox-4-stacking.clar" clarity_version = 2 epoch = 2.5 +[contracts.ccip025-extend-sunset-period-3] +path = "contracts/proposals/ccip025-extend-sunset-period-3.clar" +clarity_version = 2 +epoch = 2.5 + # CITYCOINS PROTOCOL TRAITS [contracts.extension-trait] diff --git a/contracts/proposals/ccip025-extend-sunset-period-3.clar b/contracts/proposals/ccip025-extend-sunset-period-3.clar new file mode 100644 index 0000000..585a5d4 --- /dev/null +++ b/contracts/proposals/ccip025-extend-sunset-period-3.clar @@ -0,0 +1,276 @@ +;; TRAITS + +(impl-trait .proposal-trait.proposal-trait) +(impl-trait .ccip015-trait.ccip015-trait) + +;; ERRORS + +(define-constant ERR_PANIC (err u25000)) +(define-constant ERR_VOTED_ALREADY (err u25001)) +(define-constant ERR_NOTHING_STACKED (err u25002)) +(define-constant ERR_USER_NOT_FOUND (err u25003)) +(define-constant ERR_PROPOSAL_NOT_ACTIVE (err u25004)) +(define-constant ERR_PROPOSAL_STILL_ACTIVE (err u25005)) +(define-constant ERR_NO_CITY_ID (err u25006)) +(define-constant ERR_VOTE_FAILED (err u25007)) +(define-constant ERR_SAVING_VOTE (err u25008)) + +;; CONSTANTS + +(define-constant SELF (as-contract tx-sender)) +(define-constant CCIP_025 { + name: "Extend Direct Execute Sunset Period 3", + link: "https://github.com/citycoins/governance/blob/feat/add-ccip-025/ccips/ccip-025/ccip-025-extend-direct-execute-sunset-period-3.md", + hash: "TBD", +}) + +(define-constant VOTE_SCALE_FACTOR (pow u10 u16)) ;; 16 decimal places +(define-constant SUNSET_BLOCK u199668) + +;; set city ID +(define-constant MIA_ID (default-to u1 (contract-call? .ccd004-city-registry get-city-id "mia"))) + +;; DATA VARS + +;; vote block heights +(define-data-var voteActive bool true) +(define-data-var voteStart uint u0) +(define-data-var voteEnd uint u0) + +(var-set voteStart block-height) + +;; vote tracking +(define-data-var yesVotes uint u0) +(define-data-var yesTotal uint u0) +(define-data-var noVotes uint u0) +(define-data-var noTotal uint u0) + +;; DATA MAPS + +(define-map CityVotes + uint ;; city ID + { ;; vote + totalAmountYes: uint, + totalAmountNo: uint, + totalVotesYes: uint, + totalVotesNo: uint, + } +) + +(define-map UserVotes + uint ;; user ID + { ;; vote + vote: bool, + mia: uint, + } +) + +;; PUBLIC FUNCTIONS + +(define-public (execute (sender principal)) + (begin + ;; check vote complete/passed + (try! (is-executable)) + ;; update vote variables + (var-set voteEnd block-height) + (var-set voteActive false) + ;; extend sunset height in ccd001-direct-execute + (try! (contract-call? .ccd001-direct-execute set-sunset-block SUNSET_BLOCK)) + (ok true) + ) +) + +(define-public (vote-on-proposal (vote bool)) + (let + ( + (voterId (unwrap! (contract-call? .ccd003-user-registry get-user-id contract-caller) ERR_USER_NOT_FOUND)) + (voterRecord (map-get? UserVotes voterId)) + ) + ;; check if vote is active + (asserts! (is-vote-active) ERR_PROPOSAL_NOT_ACTIVE) + ;; check if vote record exists for user + (match voterRecord record + ;; if the voterRecord exists + (let + ( + (oldVote (get vote record)) + (miaVoteAmount (get mia record)) + ) + ;; check vote is not the same as before + (asserts! (not (is-eq oldVote vote)) ERR_VOTED_ALREADY) + ;; record the new vote for the user + (map-set UserVotes voterId + (merge record { vote: vote }) + ) + ;; update vote stats for MIA + (update-city-votes MIA_ID miaVoteAmount vote true) + ;; print voter info + (print { + notification: "vote-on-ccip-025", + payload: (get-voter-info voterId) + }) + (ok true) + ) + ;; if the voterRecord does not exist + (let + ( + (miaVoteAmount (scale-down (default-to u0 (get-mia-vote voterId true)))) + ) + ;; check that the user has a positive vote + (asserts! (> miaVoteAmount u0) ERR_NOTHING_STACKED) + ;; insert new user vote record + (asserts! (map-insert UserVotes voterId { + vote: vote, + mia: miaVoteAmount + }) ERR_SAVING_VOTE) + ;; update vote stats for MIA + (update-city-votes MIA_ID miaVoteAmount vote false) + ;; print voter info + (print { + notification: "vote-on-ccip-025", + payload: (get-voter-info voterId) + }) + (ok true) + ) + ) + ) +) + +;; READ ONLY FUNCTIONS + +(define-read-only (is-executable) + (begin + ;; check that there is at least one vote + (asserts! (or (> (var-get yesVotes) u0) (> (var-get noVotes) u0)) ERR_VOTE_FAILED) + ;; check that yes total is more than no total + (asserts! (> (var-get yesTotal) (var-get noTotal)) ERR_VOTE_FAILED) + (ok true) + ) +) + +(define-read-only (is-vote-active) + (var-get voteActive) +) + +(define-read-only (get-proposal-info) + (some CCIP_025) +) + +(define-read-only (get-vote-period) + (if (and + (> (var-get voteStart) u0) + (> (var-get voteEnd) u0)) + ;; if both are set, return values + (some { + startBlock: (var-get voteStart), + endBlock: (var-get voteEnd), + length: (- (var-get voteEnd) (var-get voteStart)) + }) + ;; else return none + none + ) +) + +(define-read-only (get-vote-total-mia) + (map-get? CityVotes MIA_ID) +) + +(define-read-only (get-vote-total-mia-or-default) + (default-to { totalAmountYes: u0, totalAmountNo: u0, totalVotesYes: u0, totalVotesNo: u0 } (get-vote-total-mia)) +) + +(define-read-only (get-vote-totals) + (let + ( + (miaRecord (get-vote-total-mia-or-default)) + ) + (some { + mia: miaRecord, + totals: { + totalAmountYes: (get totalAmountYes miaRecord), + totalAmountNo: (get totalAmountNo miaRecord), + totalVotesYes: (get totalVotesYes miaRecord), + totalVotesNo: (get totalVotesNo miaRecord), + } + }) + ) +) + +(define-read-only (get-voter-info (id uint)) + (map-get? UserVotes id) +) + +;; MIA vote calculation +;; returns (some uint) or (none) +;; optionally scaled by VOTE_SCALE_FACTOR (10^6) +(define-read-only (get-mia-vote (userId uint) (scaled bool)) + (let + ( + ;; MAINNET: MIA cycle 82 / first block BTC 838,250 STX 145,643 + ;; cycle 2 / u4500 used in tests + (cycle82Hash (unwrap! (get-block-hash u4500) none)) + (cycle82Data (at-block cycle82Hash (contract-call? .ccd007-citycoin-stacking get-stacker MIA_ID u2 userId))) + (cycle82Amount (get stacked cycle82Data)) + ;; MAINNET: MIA cycle 83 / first block BTC 840,350 STX 147,282 + ;; cycle 3 / u6600 used in tests + (cycle83Hash (unwrap! (get-block-hash u6600) none)) + (cycle83Data (at-block cycle83Hash (contract-call? .ccd007-citycoin-stacking get-stacker MIA_ID u3 userId))) + (cycle83Amount (get stacked cycle83Data)) + ;; MIA vote calculation + (scaledVote (/ (+ (scale-up cycle82Amount) (scale-up cycle83Amount)) u2)) + ) + ;; check that at least one value is positive + (asserts! (or (> cycle82Amount u0) (> cycle83Amount u0)) none) + ;; return scaled or unscaled value + (if scaled (some scaledVote) (some (/ scaledVote VOTE_SCALE_FACTOR))) + ) +) + +;; PRIVATE FUNCTIONS + +;; update city vote map +(define-private (update-city-votes (cityId uint) (voteAmount uint) (vote bool) (changedVote bool)) + (let + ( + (cityRecord (default-to + { totalAmountYes: u0, totalAmountNo: u0, totalVotesYes: u0, totalVotesNo: u0 } + (map-get? CityVotes cityId))) + ) + ;; do not record if amount is 0 + (if (> voteAmount u0) + ;; handle vote + (if vote + ;; handle yes vote + (map-set CityVotes cityId { + totalAmountYes: (+ voteAmount (get totalAmountYes cityRecord)), + totalVotesYes: (+ u1 (get totalVotesYes cityRecord)), + totalAmountNo: (if changedVote (- (get totalAmountNo cityRecord) voteAmount) (get totalAmountNo cityRecord)), + totalVotesNo: (if changedVote (- (get totalVotesNo cityRecord) u1) (get totalVotesNo cityRecord)) + }) + ;; handle no vote + (map-set CityVotes cityId { + totalAmountYes: (if changedVote (- (get totalAmountYes cityRecord) voteAmount) (get totalAmountYes cityRecord)), + totalVotesYes: (if changedVote (- (get totalVotesYes cityRecord) u1) (get totalVotesYes cityRecord)), + totalAmountNo: (+ voteAmount (get totalAmountNo cityRecord)), + totalVotesNo: (+ u1 (get totalVotesNo cityRecord)), + }) + ) + ;; ignore calls with vote amount equal to 0 + false) + ) +) + +;; get block hash by height +(define-private (get-block-hash (blockHeight uint)) + (get-block-info? id-header-hash blockHeight) +) + +;; CREDIT: ALEX math-fixed-point-16.clar + +(define-private (scale-up (a uint)) + (* a VOTE_SCALE_FACTOR) +) + +(define-private (scale-down (a uint)) + (/ a VOTE_SCALE_FACTOR) +) From a5fb412b9bd3b2e0a0b786df297e5a73cb07e349 Mon Sep 17 00:00:00 2001 From: "Jason Schrader (aider)" Date: Tue, 29 Oct 2024 10:44:23 -0700 Subject: [PATCH 02/11] feat: add CCIP-025 extend sunset period model --- .../ccip025-extend-sunset-period-3.model.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 models/proposals/ccip025-extend-sunset-period-3.model.ts diff --git a/models/proposals/ccip025-extend-sunset-period-3.model.ts b/models/proposals/ccip025-extend-sunset-period-3.model.ts new file mode 100644 index 0000000..661f906 --- /dev/null +++ b/models/proposals/ccip025-extend-sunset-period-3.model.ts @@ -0,0 +1,80 @@ +import { Chain, Account, Tx, types, ReadOnlyFn } from "../../utils/deps.ts"; +import { PROPOSALS } from "../../utils/common.ts"; + +enum ErrCode { + ERR_PANIC = 25000, + ERR_VOTED_ALREADY, + ERR_NOTHING_STACKED, + ERR_USER_NOT_FOUND, + ERR_PROPOSAL_NOT_ACTIVE, + ERR_PROPOSAL_STILL_ACTIVE, + ERR_NO_CITY_ID, + ERR_VOTE_FAILED, + ERR_SAVING_VOTE, +} + +export class CCIP025ExtendDirectExecuteSunsetPeriod { + name = PROPOSALS.CCIP_025; + static readonly ErrCode = ErrCode; + chain: Chain; + deployer: Account; + + constructor(chain: Chain, deployer: Account) { + this.chain = chain; + this.deployer = deployer; + } + + // public functions + + execute(sender: Account) { + return Tx.contractCall(this.name, "execute", [types.principal(sender.address)], sender.address); + } + + voteOnProposal(sender: Account, vote: boolean) { + return Tx.contractCall(this.name, "vote-on-proposal", [types.bool(vote)], sender.address); + } + + // read-only functions + + isExecutable() { + return this.callReadOnlyFn("is-executable"); + } + + isVoteActive() { + return this.callReadOnlyFn("is-vote-active"); + } + + getProposalInfo() { + return this.callReadOnlyFn("get-proposal-info"); + } + + getVotePeriod() { + return this.callReadOnlyFn("get-vote-period"); + } + + getVoteTotalMia() { + return this.callReadOnlyFn("get-vote-total-mia"); + } + + getVoteTotalMiaOrDefault() { + return this.callReadOnlyFn("get-vote-total-mia-or-default"); + } + + getVoteTotals() { + return this.callReadOnlyFn("get-vote-totals"); + } + + getVoterInfo(userId: number) { + return this.callReadOnlyFn("get-voter-info", [types.uint(userId)]); + } + + getMiaVote(userId: number, scaled: boolean) { + return this.callReadOnlyFn("get-mia-vote", [types.uint(userId), types.bool(scaled)]); + } + + // read-only function helper + private callReadOnlyFn(method: string, args: Array = [], sender: Account = this.deployer): ReadOnlyFn { + const result = this.chain.callReadOnlyFn(this.name, method, args, sender?.address); + return result; + } +} From 65a2e51395354692590198e8500e938307edddd9 Mon Sep 17 00:00:00 2001 From: "Jason Schrader (aider)" Date: Tue, 29 Oct 2024 10:44:49 -0700 Subject: [PATCH 03/11] feat: Add CCIP_025 constant to PROPOSALS object in utils/common.ts --- utils/common.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/utils/common.ts b/utils/common.ts index d9a2d88..b2df99a 100644 --- a/utils/common.ts +++ b/utils/common.ts @@ -45,6 +45,7 @@ export const PROPOSALS = { CCIP_021: ADDRESS.concat(".ccip021-extend-sunset-period-2"), CCIP_022: ADDRESS.concat(".ccip022-treasury-redemption-nyc"), CCIP_024: ADDRESS.concat(".ccip024-miamicoin-signal-vote"), + CCIP_025: ADDRESS.concat(".ccip025-extend-sunset-period-3"), TEST_CCD001_DIRECT_EXECUTE_001: ADDRESS.concat(".test-ccd001-direct-execute-001"), TEST_CCD001_DIRECT_EXECUTE_002: ADDRESS.concat(".test-ccd001-direct-execute-002"), TEST_CCD001_DIRECT_EXECUTE_003: ADDRESS.concat(".test-ccd001-direct-execute-003"), From 4e2446e82e55d62ec295cd3921fc22c0bf169a83 Mon Sep 17 00:00:00 2001 From: "Jason Schrader (aider)" Date: Tue, 29 Oct 2024 10:47:54 -0700 Subject: [PATCH 04/11] feat: add tests for CCIP-025 --- .../ccip025-extend-sunset-period-3.model.ts | 80 +++++++ .../ccip025-extend-sunset-period-3.test.ts | 204 ++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 tests/proposals/ccip025-extend-sunset-period-3.test.ts diff --git a/models/proposals/ccip025-extend-sunset-period-3.model.ts b/models/proposals/ccip025-extend-sunset-period-3.model.ts index 661f906..59e080c 100644 --- a/models/proposals/ccip025-extend-sunset-period-3.model.ts +++ b/models/proposals/ccip025-extend-sunset-period-3.model.ts @@ -78,3 +78,83 @@ export class CCIP025ExtendDirectExecuteSunsetPeriod { return result; } } +import { Chain, Account, Tx, types, ReadOnlyFn } from "../../utils/deps.ts"; +import { PROPOSALS } from "../../utils/common.ts"; + +enum ErrCode { + ERR_PANIC = 25000, + ERR_VOTED_ALREADY, + ERR_NOTHING_STACKED, + ERR_USER_NOT_FOUND, + ERR_PROPOSAL_NOT_ACTIVE, + ERR_PROPOSAL_STILL_ACTIVE, + ERR_NO_CITY_ID, + ERR_VOTE_FAILED, + ERR_SAVING_VOTE, +} + +export class CCIP025ExtendDirectExecuteSunsetPeriod { + name = PROPOSALS.CCIP_025; + static readonly ErrCode = ErrCode; + chain: Chain; + deployer: Account; + + constructor(chain: Chain, deployer: Account) { + this.chain = chain; + this.deployer = deployer; + } + + // public functions + + execute(sender: Account) { + return Tx.contractCall(this.name, "execute", [types.principal(sender.address)], sender.address); + } + + voteOnProposal(sender: Account, vote: boolean) { + return Tx.contractCall(this.name, "vote-on-proposal", [types.bool(vote)], sender.address); + } + + // read-only functions + + isExecutable() { + return this.callReadOnlyFn("is-executable"); + } + + isVoteActive() { + return this.callReadOnlyFn("is-vote-active"); + } + + getProposalInfo() { + return this.callReadOnlyFn("get-proposal-info"); + } + + getVotePeriod() { + return this.callReadOnlyFn("get-vote-period"); + } + + getVoteTotalMia() { + return this.callReadOnlyFn("get-vote-total-mia"); + } + + getVoteTotalMiaOrDefault() { + return this.callReadOnlyFn("get-vote-total-mia-or-default"); + } + + getVoteTotals() { + return this.callReadOnlyFn("get-vote-totals"); + } + + getVoterInfo(userId: number) { + return this.callReadOnlyFn("get-voter-info", [types.uint(userId)]); + } + + getMiaVote(userId: number, scaled: boolean) { + return this.callReadOnlyFn("get-mia-vote", [types.uint(userId), types.bool(scaled)]); + } + + // read-only function helper + private callReadOnlyFn(method: string, args: Array = [], sender: Account = this.deployer): ReadOnlyFn { + const result = this.chain.callReadOnlyFn(this.name, method, args, sender?.address); + return result; + } +} diff --git a/tests/proposals/ccip025-extend-sunset-period-3.test.ts b/tests/proposals/ccip025-extend-sunset-period-3.test.ts new file mode 100644 index 0000000..0f3ee7a --- /dev/null +++ b/tests/proposals/ccip025-extend-sunset-period-3.test.ts @@ -0,0 +1,204 @@ +import { Account, Clarinet, Chain, types, assertEquals } from "../../utils/deps.ts"; +import { constructAndPassProposal, mia, PROPOSALS } from "../../utils/common.ts"; +import { CCD007CityStacking } from "../../models/extensions/ccd007-citycoin-stacking.model.ts"; +import { CCIP025ExtendDirectExecuteSunsetPeriod } from "../../models/proposals/ccip025-extend-sunset-period-3.model.ts"; + +Clarinet.test({ + name: "ccip-025: execute() fails with ERR_VOTE_FAILED if there are no votes", + fn(chain: Chain, accounts: Map) { + // arrange + const sender = accounts.get("deployer")!; + const ccip025 = new CCIP025ExtendDirectExecuteSunsetPeriod(chain, sender); + + // act + const block = chain.mineBlock([ccip025.execute(sender)]); + + // assert + block.receipts[0].result.expectErr().expectUint(CCIP025ExtendDirectExecuteSunsetPeriod.ErrCode.ERR_VOTE_FAILED); + }, +}); + +Clarinet.test({ + name: "ccip-025: execute() fails with ERR_VOTE_FAILED if there are more no than yes votes", + fn(chain: Chain, accounts: Map) { + // arrange + const sender = accounts.get("deployer")!; + const user1 = accounts.get("wallet_1")!; + const user2 = accounts.get("wallet_2")!; + const ccd007CityStacking = new CCD007CityStacking(chain, sender, "ccd007-citycoin-stacking"); + const ccip025 = new CCIP025ExtendDirectExecuteSunsetPeriod(chain, sender); + + const amountStacked = 500; + const lockPeriod = 10; + + // progress the chain to avoid underflow in + // stacking reward cycle calculation + chain.mineEmptyBlockUntil(CCD007CityStacking.FIRST_STACKING_BLOCK); + + // stack first cycle u1, last cycle u10 + const stackingBlock = chain.mineBlock([ + ccd007CityStacking.stack(user1, mia.cityName, amountStacked, lockPeriod), + ccd007CityStacking.stack(user2, mia.cityName, amountStacked / 2, lockPeriod) + ]); + stackingBlock.receipts[0].result.expectOk().expectBool(true); + + // progress the chain to cycle 5 + chain.mineEmptyBlockUntil(CCD007CityStacking.REWARD_CYCLE_LENGTH * 6 + 10); + + // act + // execute two no votes + chain.mineBlock([ + ccip025.voteOnProposal(user1, false), + ccip025.voteOnProposal(user2, false) + ]); + + // execute ccip-025 + const block = chain.mineBlock([ccip025.execute(sender)]); + + // assert + block.receipts[0].result.expectErr().expectUint(CCIP025ExtendDirectExecuteSunsetPeriod.ErrCode.ERR_VOTE_FAILED); + }, +}); + +Clarinet.test({ + name: "ccip-025: execute() succeeds if there is a single yes vote", + fn(chain: Chain, accounts: Map) { + // arrange + const sender = accounts.get("deployer")!; + const user1 = accounts.get("wallet_1")!; + const ccd007CityStacking = new CCD007CityStacking(chain, sender, "ccd007-citycoin-stacking"); + const ccip025 = new CCIP025ExtendDirectExecuteSunsetPeriod(chain, sender); + + const amountStacked = 500; + const lockPeriod = 10; + + // progress the chain to avoid underflow in + // stacking reward cycle calculation + chain.mineEmptyBlockUntil(CCD007CityStacking.FIRST_STACKING_BLOCK); + + // stack first cycle u1, last cycle u10 + const stackingBlock = chain.mineBlock([ + ccd007CityStacking.stack(user1, mia.cityName, amountStacked, lockPeriod) + ]); + stackingBlock.receipts[0].result.expectOk().expectBool(true); + + // progress the chain to cycle 5 + chain.mineEmptyBlockUntil(CCD007CityStacking.REWARD_CYCLE_LENGTH * 6 + 10); + + // act + // execute single yes vote + const votingBlock = chain.mineBlock([ccip025.voteOnProposal(user1, true)]); + + // execute ccip-025 + const block = chain.mineBlock([ccip025.execute(sender)]); + + // assert + block.receipts[0].result.expectOk().expectBool(true); + }, +}); + +Clarinet.test({ + name: "ccip-025: vote-on-proposal() fails with ERR_USER_NOT_FOUND if user is not registered", + fn(chain: Chain, accounts: Map) { + // arrange + const sender = accounts.get("deployer")!; + const user3 = accounts.get("wallet_3")!; + const ccip025 = new CCIP025ExtendDirectExecuteSunsetPeriod(chain, sender); + + // act + const block = chain.mineBlock([ccip025.voteOnProposal(user3, true)]); + + // assert + block.receipts[0].result.expectErr().expectUint(CCIP025ExtendDirectExecuteSunsetPeriod.ErrCode.ERR_USER_NOT_FOUND); + }, +}); + +Clarinet.test({ + name: "ccip-025: vote-on-proposal() fails with ERR_NOTHING_STACKED if user has no stacking history", + fn(chain: Chain, accounts: Map) { + // arrange + const sender = accounts.get("deployer")!; + const user1 = accounts.get("wallet_1")!; + const ccip025 = new CCIP025ExtendDirectExecuteSunsetPeriod(chain, sender); + + // register user but don't stack + constructAndPassProposal(chain, accounts, PROPOSALS.TEST_CCD003_USER_REGISTRY_001); + + // act + const block = chain.mineBlock([ccip025.voteOnProposal(user1, true)]); + + // assert + block.receipts[0].result.expectErr().expectUint(CCIP025ExtendDirectExecuteSunsetPeriod.ErrCode.ERR_NOTHING_STACKED); + }, +}); + +Clarinet.test({ + name: "ccip-025: vote-on-proposal() fails with ERR_VOTED_ALREADY if user votes same way twice", + fn(chain: Chain, accounts: Map) { + // arrange + const sender = accounts.get("deployer")!; + const user1 = accounts.get("wallet_1")!; + const ccd007CityStacking = new CCD007CityStacking(chain, sender, "ccd007-citycoin-stacking"); + const ccip025 = new CCIP025ExtendDirectExecuteSunsetPeriod(chain, sender); + + const amountStacked = 500; + const lockPeriod = 10; + + chain.mineEmptyBlockUntil(CCD007CityStacking.FIRST_STACKING_BLOCK); + chain.mineBlock([ccd007CityStacking.stack(user1, mia.cityName, amountStacked, lockPeriod)]); + chain.mineEmptyBlockUntil(CCD007CityStacking.REWARD_CYCLE_LENGTH * 6 + 10); + + // first vote + chain.mineBlock([ccip025.voteOnProposal(user1, true)]); + + // act + const block = chain.mineBlock([ccip025.voteOnProposal(user1, true)]); + + // assert + block.receipts[0].result.expectErr().expectUint(CCIP025ExtendDirectExecuteSunsetPeriod.ErrCode.ERR_VOTED_ALREADY); + }, +}); + +Clarinet.test({ + name: "ccip-025: read-only functions return expected values", + fn(chain: Chain, accounts: Map) { + // arrange + const sender = accounts.get("deployer")!; + const user1 = accounts.get("wallet_1")!; + const ccd007CityStacking = new CCD007CityStacking(chain, sender, "ccd007-citycoin-stacking"); + const ccip025 = new CCIP025ExtendDirectExecuteSunsetPeriod(chain, sender); + + const amountStacked = 500; + const lockPeriod = 10; + + chain.mineEmptyBlockUntil(CCD007CityStacking.FIRST_STACKING_BLOCK); + + // stack and progress chain + chain.mineBlock([ccd007CityStacking.stack(user1, mia.cityName, amountStacked, lockPeriod)]); + chain.mineEmptyBlockUntil(CCD007CityStacking.REWARD_CYCLE_LENGTH * 6 + 10); + + // act + const voteBlock = chain.mineBlock([ccip025.voteOnProposal(user1, true)]); + const executeBlock = chain.mineBlock([ccip025.execute(sender)]); + + // assert + ccip025.isVoteActive().result.expectBool(false); + + const proposalInfo = { + name: types.ascii("Extend Direct Execute Sunset Period 3"), + link: types.ascii("https://github.com/citycoins/governance/blob/feat/add-ccip-025/ccips/ccip-025/ccip-025-extend-direct-execute-sunset-period-3.md"), + hash: types.ascii("TBD"), + }; + assertEquals(ccip025.getProposalInfo().result.expectSome().expectTuple(), proposalInfo); + + const votePeriod = ccip025.getVotePeriod().result.expectSome().expectTuple(); + assertEquals(votePeriod.startBlock, types.uint(0)); + assertEquals(votePeriod.endBlock, types.uint(executeBlock.height)); + + const voteTotals = ccip025.getVoteTotals().result.expectSome().expectTuple(); + assertEquals(voteTotals.totals.totalAmountYes, types.uint(amountStacked)); + assertEquals(voteTotals.totals.totalVotesYes, types.uint(1)); + assertEquals(voteTotals.totals.totalAmountNo, types.uint(0)); + assertEquals(voteTotals.totals.totalVotesNo, types.uint(0)); + }, +}); From de3cfe4b1a4857aa2bde47b2cfc3e39fc73b1851 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Tue, 29 Oct 2024 14:57:49 -0700 Subject: [PATCH 05/11] fix: initialize aider --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index f512125..af7a4a7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ dist history.txt coverage.lcov node_modules +.aider* +.env From ef479912a639ed511e1a26980e5e40a1264debb0 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Tue, 29 Oct 2024 14:59:25 -0700 Subject: [PATCH 06/11] fix: update to clarinet default templates --- deployments/default.devnet-plan.yaml | 66 ---------------------------- package.json | 2 +- tsconfig.json | 25 +++++++---- 3 files changed, 18 insertions(+), 75 deletions(-) delete mode 100644 deployments/default.devnet-plan.yaml diff --git a/deployments/default.devnet-plan.yaml b/deployments/default.devnet-plan.yaml deleted file mode 100644 index 781d969..0000000 --- a/deployments/default.devnet-plan.yaml +++ /dev/null @@ -1,66 +0,0 @@ ---- -id: 0 -name: Devnet deployment -network: devnet -stacks-node: "http://localhost:20443" -bitcoin-node: "http://devnet:devnet@localhost:18443" -plan: - batches: - - id: 0 - transactions: - - contract-publish: - contract-name: extension-trait - expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - cost: 940 - path: contracts/traits/extension-trait.clar - anchor-block-only: true - clarity-version: 1 - - contract-publish: - contract-name: proposal-trait - expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - cost: 820 - path: contracts/traits/proposal-trait.clar - anchor-block-only: true - clarity-version: 1 - - contract-publish: - contract-name: base-dao - expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - cost: 27410 - path: contracts/base-dao.clar - anchor-block-only: true - clarity-version: 1 - - contract-publish: - contract-name: ccd001-direct-execute - expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - cost: 34770 - path: contracts/extensions/ccd001-direct-execute.clar - anchor-block-only: true - clarity-version: 1 - - contract-publish: - contract-name: ccip012-bootstrap - expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - cost: 13900 - path: contracts/proposals/ccip012-bootstrap.clar - anchor-block-only: true - clarity-version: 1 - - contract-publish: - contract-name: sip009-nft-trait - expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - cost: 2880 - path: contracts/traits/sip009-nft-trait.clar - anchor-block-only: true - clarity-version: 1 - - contract-publish: - contract-name: sip010-ft-trait - expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - cost: 4290 - path: contracts/traits/sip010-ft-trait.clar - anchor-block-only: true - clarity-version: 1 - - contract-publish: - contract-name: ccd002-treasury - expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - cost: 49900 - path: contracts/extensions/ccd002-treasury.clar - anchor-block-only: true - clarity-version: 1 diff --git a/package.json b/package.json index bb354d1..68fb098 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "scripts": { - "test": "vitest run ccip022", + "test": "vitest run", "test:report": "vitest run -- --coverage --costs", "test:watch": "chokidar \"tests/**/*.ts\" \"contracts/**/*.clar\" -c \"npm run test:report\"" }, diff --git a/tsconfig.json b/tsconfig.json index ade2eb8..1e43ae4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,22 @@ { "compilerOptions": { - "target": "ES2020", - "module": "ES2020", - "moduleResolution": "node", - "esModuleInterop": true, + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, - "outDir": "./dist", - "allowSyntheticDefaultImports": true, - "skipLibCheck": true + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true }, - "include": ["simulations/**/*"] + "include": ["node_modules/@hirosystems/clarinet-sdk/vitest-helpers/src", "tests"] } From 14d76699023753777bcbca819b1769c06bb05099 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Tue, 29 Oct 2024 14:59:36 -0700 Subject: [PATCH 07/11] fix: add model and tests for ccip-025 --- Clarinet-legacy.toml | 10 ++ Clarinet.toml | 5 + .../ccip025-extend-sunset-period-3.clar | 20 +-- .../ccip025-extend-sunset-period-3.model.ts | 80 --------- ...st-ccip025-extend-sunset-period-3-001.clar | 36 ++++ .../ccip025-extend-sunset-period-3.test.ts | 154 ++++++++++++++---- utils/common.ts | 3 +- 7 files changed, 183 insertions(+), 125 deletions(-) create mode 100644 tests/contracts/proposals/test-ccip025-extend-sunset-period-3-001.clar diff --git a/Clarinet-legacy.toml b/Clarinet-legacy.toml index fa7a4c1..667fa15 100644 --- a/Clarinet-legacy.toml +++ b/Clarinet-legacy.toml @@ -184,6 +184,11 @@ path = "contracts/proposals/ccip024-miamicoin-signal-vote.clar" clarity_version = 2 epoch = 2.4 +[contracts.ccip025-extend-sunset-period-3] +path = "contracts/proposals/ccip025-extend-sunset-period-3.clar" +clarity_version = 2 +epoch = 2.4 + # CITYCOINS PROTOCOL TRAITS [contracts.extension-trait] @@ -565,6 +570,11 @@ path = "tests/contracts/proposals/test-ccip024-miamicoin-signal-vote-001.clar" clarity_version = 2 epoch = 2.4 +[contracts.test-ccip025-extend-sunset-period-3-001] +path = "tests/contracts/proposals/test-ccip025-extend-sunset-period-3-001.clar" +clarity_version = 2 +epoch = 2.4 + [repl] costs_version = 2 parser_version = 2 diff --git a/Clarinet.toml b/Clarinet.toml index 50fa3cc..8326123 100644 --- a/Clarinet.toml +++ b/Clarinet.toml @@ -587,6 +587,11 @@ path = "tests/contracts/proposals/test-ccip024-miamicoin-signal-vote-001.clar" clarity_version = 2 epoch = 2.4 +[contracts.test-ccip025-extend-sunset-period-3-001] +path = "tests/contracts/proposals/test-ccip025-extend-sunset-period-3-001.clar" +clarity_version = 2 +epoch = 2.5 + [repl] costs_version = 2 parser_version = 2 diff --git a/contracts/proposals/ccip025-extend-sunset-period-3.clar b/contracts/proposals/ccip025-extend-sunset-period-3.clar index 585a5d4..6e9e927 100644 --- a/contracts/proposals/ccip025-extend-sunset-period-3.clar +++ b/contracts/proposals/ccip025-extend-sunset-period-3.clar @@ -39,12 +39,6 @@ (var-set voteStart block-height) -;; vote tracking -(define-data-var yesVotes uint u0) -(define-data-var yesTotal uint u0) -(define-data-var noVotes uint u0) -(define-data-var noTotal uint u0) - ;; DATA MAPS (define-map CityVotes @@ -139,11 +133,17 @@ ;; READ ONLY FUNCTIONS (define-read-only (is-executable) - (begin + (let + ( + (votingRecord (unwrap! (get-vote-totals) ERR_PANIC)) + (miaRecord (get mia votingRecord)) + (voteTotals (get totals votingRecord)) + ) ;; check that there is at least one vote - (asserts! (or (> (var-get yesVotes) u0) (> (var-get noVotes) u0)) ERR_VOTE_FAILED) - ;; check that yes total is more than no total - (asserts! (> (var-get yesTotal) (var-get noTotal)) ERR_VOTE_FAILED) + (asserts! (or (> (get totalVotesYes voteTotals) u0) (> (get totalVotesNo voteTotals) u0)) ERR_VOTE_FAILED) + ;; check that the yes total is more than no total + (asserts! (> (get totalVotesYes voteTotals) (get totalVotesNo voteTotals)) ERR_VOTE_FAILED) + ;; allow execution (ok true) ) ) diff --git a/models/proposals/ccip025-extend-sunset-period-3.model.ts b/models/proposals/ccip025-extend-sunset-period-3.model.ts index 59e080c..661f906 100644 --- a/models/proposals/ccip025-extend-sunset-period-3.model.ts +++ b/models/proposals/ccip025-extend-sunset-period-3.model.ts @@ -78,83 +78,3 @@ export class CCIP025ExtendDirectExecuteSunsetPeriod { return result; } } -import { Chain, Account, Tx, types, ReadOnlyFn } from "../../utils/deps.ts"; -import { PROPOSALS } from "../../utils/common.ts"; - -enum ErrCode { - ERR_PANIC = 25000, - ERR_VOTED_ALREADY, - ERR_NOTHING_STACKED, - ERR_USER_NOT_FOUND, - ERR_PROPOSAL_NOT_ACTIVE, - ERR_PROPOSAL_STILL_ACTIVE, - ERR_NO_CITY_ID, - ERR_VOTE_FAILED, - ERR_SAVING_VOTE, -} - -export class CCIP025ExtendDirectExecuteSunsetPeriod { - name = PROPOSALS.CCIP_025; - static readonly ErrCode = ErrCode; - chain: Chain; - deployer: Account; - - constructor(chain: Chain, deployer: Account) { - this.chain = chain; - this.deployer = deployer; - } - - // public functions - - execute(sender: Account) { - return Tx.contractCall(this.name, "execute", [types.principal(sender.address)], sender.address); - } - - voteOnProposal(sender: Account, vote: boolean) { - return Tx.contractCall(this.name, "vote-on-proposal", [types.bool(vote)], sender.address); - } - - // read-only functions - - isExecutable() { - return this.callReadOnlyFn("is-executable"); - } - - isVoteActive() { - return this.callReadOnlyFn("is-vote-active"); - } - - getProposalInfo() { - return this.callReadOnlyFn("get-proposal-info"); - } - - getVotePeriod() { - return this.callReadOnlyFn("get-vote-period"); - } - - getVoteTotalMia() { - return this.callReadOnlyFn("get-vote-total-mia"); - } - - getVoteTotalMiaOrDefault() { - return this.callReadOnlyFn("get-vote-total-mia-or-default"); - } - - getVoteTotals() { - return this.callReadOnlyFn("get-vote-totals"); - } - - getVoterInfo(userId: number) { - return this.callReadOnlyFn("get-voter-info", [types.uint(userId)]); - } - - getMiaVote(userId: number, scaled: boolean) { - return this.callReadOnlyFn("get-mia-vote", [types.uint(userId), types.bool(scaled)]); - } - - // read-only function helper - private callReadOnlyFn(method: string, args: Array = [], sender: Account = this.deployer): ReadOnlyFn { - const result = this.chain.callReadOnlyFn(this.name, method, args, sender?.address); - return result; - } -} diff --git a/tests/contracts/proposals/test-ccip025-extend-sunset-period-3-001.clar b/tests/contracts/proposals/test-ccip025-extend-sunset-period-3-001.clar new file mode 100644 index 0000000..c625271 --- /dev/null +++ b/tests/contracts/proposals/test-ccip025-extend-sunset-period-3-001.clar @@ -0,0 +1,36 @@ +;; Title: Test Proposal for CCIP-025 +;; Version: 1.0.0 +;; Synopsis: Test proposal for CCIP-025 +;; Description: +;; Sets up everything required for CCIP-025 + +(impl-trait .proposal-trait.proposal-trait) + +(define-public (execute (sender principal)) + (begin + ;; Set up MIA in city registry + (try! (contract-call? .ccd004-city-registry get-or-create-city-id "mia")) + + ;; Set activation details for MIA + (try! (contract-call? .ccd005-city-data set-activation-details u1 u1 u1 u5 u1)) + + ;; Set activation status for MIA + (try! (contract-call? .ccd005-city-data set-activation-status u1 true)) + + ;; Add MIA mining treasury + (try! (contract-call? .ccd005-city-data add-treasury u1 .ccd002-treasury-mia-mining-v2 "mining")) + + ;; Add MIA stacking treasury + (try! (contract-call? .ccd005-city-data add-treasury u1 .ccd002-treasury-mia-stacking "stacking")) + + ;; Mint MIA tokens to test users + (try! (contract-call? .test-ccext-governance-token-mia mint u1000 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5)) + (try! (contract-call? .test-ccext-governance-token-mia mint u1000 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG)) + (try! (contract-call? .test-ccext-governance-token-mia mint u1000 'ST2JHG361ZXG51QTKY2NQCVBPPRRE2KZB1HR05NNC)) + + ;; Add MIA token to stacking treasury allow list + (try! (contract-call? .ccd002-treasury-mia-stacking set-allowed .test-ccext-governance-token-mia true)) + + (ok true) + ) +) diff --git a/tests/proposals/ccip025-extend-sunset-period-3.test.ts b/tests/proposals/ccip025-extend-sunset-period-3.test.ts index 0f3ee7a..4046d8b 100644 --- a/tests/proposals/ccip025-extend-sunset-period-3.test.ts +++ b/tests/proposals/ccip025-extend-sunset-period-3.test.ts @@ -1,5 +1,5 @@ import { Account, Clarinet, Chain, types, assertEquals } from "../../utils/deps.ts"; -import { constructAndPassProposal, mia, PROPOSALS } from "../../utils/common.ts"; +import { constructAndPassProposal, mia, passProposal, PROPOSALS } from "../../utils/common.ts"; import { CCD007CityStacking } from "../../models/extensions/ccd007-citycoin-stacking.model.ts"; import { CCIP025ExtendDirectExecuteSunsetPeriod } from "../../models/proposals/ccip025-extend-sunset-period-3.model.ts"; @@ -8,13 +8,39 @@ Clarinet.test({ fn(chain: Chain, accounts: Map) { // arrange const sender = accounts.get("deployer")!; - const ccip025 = new CCIP025ExtendDirectExecuteSunsetPeriod(chain, sender); + const user1 = accounts.get("wallet_1")!; + const user2 = accounts.get("wallet_2")!; + const ccd007CityStacking = new CCD007CityStacking(chain, sender, "ccd007-citycoin-stacking"); + const amountStacked = 500; + const lockPeriod = 10; + + // progress the chain to avoid underflow in + // stacking reward cycle calculation + chain.mineEmptyBlockUntil(CCD007CityStacking.FIRST_STACKING_BLOCK); + + // initialize contracts + constructAndPassProposal(chain, accounts, PROPOSALS.TEST_CCIP025_EXTEND_SUNSET_PERIOD_3_001); + + // stack first cycle u1, last cycle u10 + const stackingBlock = chain.mineBlock([ccd007CityStacking.stack(user1, mia.cityName, amountStacked, lockPeriod), ccd007CityStacking.stack(user2, mia.cityName, amountStacked / 2, lockPeriod)]); + // make sure every transaction succeeded + for (let i = 0; i < stackingBlock.receipts.length; i++) { + stackingBlock.receipts[i].result.expectOk().expectBool(true); + } + + // progress the chain to cycle 5 + // votes are counted in cycles 2-3 + // past payouts tested for cycles 1-4 + chain.mineEmptyBlockUntil(CCD007CityStacking.REWARD_CYCLE_LENGTH * 6 + 10); + ccd007CityStacking.getCurrentRewardCycle().result.expectUint(5); // act - const block = chain.mineBlock([ccip025.execute(sender)]); + const block = passProposal(chain, accounts, PROPOSALS.CCIP_025); // assert - block.receipts[0].result.expectErr().expectUint(CCIP025ExtendDirectExecuteSunsetPeriod.ErrCode.ERR_VOTE_FAILED); + block.receipts[0].result.expectOk().expectUint(1); + block.receipts[1].result.expectOk().expectUint(2); + block.receipts[2].result.expectErr().expectUint(CCIP025ExtendDirectExecuteSunsetPeriod.ErrCode.ERR_VOTE_FAILED); }, }); @@ -35,28 +61,37 @@ Clarinet.test({ // stacking reward cycle calculation chain.mineEmptyBlockUntil(CCD007CityStacking.FIRST_STACKING_BLOCK); + // initialize contracts + constructAndPassProposal(chain, accounts, PROPOSALS.TEST_CCIP025_EXTEND_SUNSET_PERIOD_3_001); + // stack first cycle u1, last cycle u10 - const stackingBlock = chain.mineBlock([ - ccd007CityStacking.stack(user1, mia.cityName, amountStacked, lockPeriod), - ccd007CityStacking.stack(user2, mia.cityName, amountStacked / 2, lockPeriod) - ]); - stackingBlock.receipts[0].result.expectOk().expectBool(true); + const stackingBlock = chain.mineBlock([ccd007CityStacking.stack(user1, mia.cityName, amountStacked, lockPeriod), ccd007CityStacking.stack(user2, mia.cityName, amountStacked / 2, lockPeriod)]); + // make sure every transaction succeeded + for (let i = 0; i < stackingBlock.receipts.length; i++) { + stackingBlock.receipts[i].result.expectOk().expectBool(true); + } // progress the chain to cycle 5 + // votes are counted in cycles 2-3 + // past payouts tested for cycles 1-4 chain.mineEmptyBlockUntil(CCD007CityStacking.REWARD_CYCLE_LENGTH * 6 + 10); + ccd007CityStacking.getCurrentRewardCycle().result.expectUint(5); // act + // execute two no votes - chain.mineBlock([ - ccip025.voteOnProposal(user1, false), - ccip025.voteOnProposal(user2, false) - ]); + const votingBlock = chain.mineBlock([ccip025.voteOnProposal(user1, false), ccip025.voteOnProposal(user2, false)]); + for (let i = 0; i < votingBlock.receipts.length; i++) { + votingBlock.receipts[i].result.expectOk().expectBool(true); + } // execute ccip-025 - const block = chain.mineBlock([ccip025.execute(sender)]); + const block = passProposal(chain, accounts, PROPOSALS.CCIP_025); // assert - block.receipts[0].result.expectErr().expectUint(CCIP025ExtendDirectExecuteSunsetPeriod.ErrCode.ERR_VOTE_FAILED); + block.receipts[0].result.expectOk().expectUint(1); + block.receipts[1].result.expectOk().expectUint(2); + block.receipts[2].result.expectErr().expectUint(CCIP025ExtendDirectExecuteSunsetPeriod.ErrCode.ERR_VOTE_FAILED); }, }); @@ -76,24 +111,36 @@ Clarinet.test({ // stacking reward cycle calculation chain.mineEmptyBlockUntil(CCD007CityStacking.FIRST_STACKING_BLOCK); + // initialize contracts + constructAndPassProposal(chain, accounts, PROPOSALS.TEST_CCIP025_EXTEND_SUNSET_PERIOD_3_001); + // stack first cycle u1, last cycle u10 - const stackingBlock = chain.mineBlock([ - ccd007CityStacking.stack(user1, mia.cityName, amountStacked, lockPeriod) - ]); - stackingBlock.receipts[0].result.expectOk().expectBool(true); + const stackingBlock = chain.mineBlock([ccd007CityStacking.stack(user1, mia.cityName, amountStacked, lockPeriod)]); + // make sure every transaction succeeded + for (let i = 0; i < stackingBlock.receipts.length; i++) { + stackingBlock.receipts[i].result.expectOk().expectBool(true); + } // progress the chain to cycle 5 chain.mineEmptyBlockUntil(CCD007CityStacking.REWARD_CYCLE_LENGTH * 6 + 10); + ccd007CityStacking.getCurrentRewardCycle().result.expectUint(5); // act // execute single yes vote const votingBlock = chain.mineBlock([ccip025.voteOnProposal(user1, true)]); + for (let i = 0; i < votingBlock.receipts.length; i++) { + votingBlock.receipts[i].result.expectOk().expectBool(true); + } // execute ccip-025 - const block = chain.mineBlock([ccip025.execute(sender)]); + const block = passProposal(chain, accounts, PROPOSALS.CCIP_025); + + const voteTotals = ccip025.getVoteTotals().result.expectSome().expectTuple(); // assert - block.receipts[0].result.expectOk().expectBool(true); + block.receipts[0].result.expectOk().expectUint(1); + block.receipts[1].result.expectOk().expectUint(2); + block.receipts[2].result.expectOk().expectUint(3); }, }); @@ -144,12 +191,29 @@ Clarinet.test({ const amountStacked = 500; const lockPeriod = 10; + // progress the chain to avoid underflow in + // stacking reward cycle calculation chain.mineEmptyBlockUntil(CCD007CityStacking.FIRST_STACKING_BLOCK); - chain.mineBlock([ccd007CityStacking.stack(user1, mia.cityName, amountStacked, lockPeriod)]); + + // initialize contracts + constructAndPassProposal(chain, accounts, PROPOSALS.TEST_CCIP025_EXTEND_SUNSET_PERIOD_3_001); + + // stack first cycle u1, last cycle u10 + const stackingBlock = chain.mineBlock([ccd007CityStacking.stack(user1, mia.cityName, amountStacked, lockPeriod)]); + // make sure every transaction succeeded + for (let i = 0; i < stackingBlock.receipts.length; i++) { + stackingBlock.receipts[i].result.expectOk().expectBool(true); + } + + // progress the chain to cycle 5 chain.mineEmptyBlockUntil(CCD007CityStacking.REWARD_CYCLE_LENGTH * 6 + 10); + ccd007CityStacking.getCurrentRewardCycle().result.expectUint(5); // first vote - chain.mineBlock([ccip025.voteOnProposal(user1, true)]); + const firstVote = chain.mineBlock([ccip025.voteOnProposal(user1, true)]); + for (let i = 0; i < firstVote.receipts.length; i++) { + firstVote.receipts[i].result.expectOk().expectBool(true); + } // act const block = chain.mineBlock([ccip025.voteOnProposal(user1, true)]); @@ -165,25 +229,46 @@ Clarinet.test({ // arrange const sender = accounts.get("deployer")!; const user1 = accounts.get("wallet_1")!; + const user2 = accounts.get("wallet_2")!; + const user3 = accounts.get("wallet_3")!; const ccd007CityStacking = new CCD007CityStacking(chain, sender, "ccd007-citycoin-stacking"); const ccip025 = new CCIP025ExtendDirectExecuteSunsetPeriod(chain, sender); const amountStacked = 500; const lockPeriod = 10; + // progress the chain to avoid underflow in + // stacking reward cycle calculation chain.mineEmptyBlockUntil(CCD007CityStacking.FIRST_STACKING_BLOCK); - - // stack and progress chain - chain.mineBlock([ccd007CityStacking.stack(user1, mia.cityName, amountStacked, lockPeriod)]); + + // initialize contracts + constructAndPassProposal(chain, accounts, PROPOSALS.TEST_CCIP025_EXTEND_SUNSET_PERIOD_3_001); + + // stack first cycle u1, last cycle u10 + const stackingBlock = chain.mineBlock([ccd007CityStacking.stack(user1, mia.cityName, amountStacked, lockPeriod)]); + // make sure every transaction succeeded + for (let i = 0; i < stackingBlock.receipts.length; i++) { + stackingBlock.receipts[i].result.expectOk().expectBool(true); + } + + // progress the chain to cycle 5 chain.mineEmptyBlockUntil(CCD007CityStacking.REWARD_CYCLE_LENGTH * 6 + 10); + ccd007CityStacking.getCurrentRewardCycle().result.expectUint(5); // act const voteBlock = chain.mineBlock([ccip025.voteOnProposal(user1, true)]); - const executeBlock = chain.mineBlock([ccip025.execute(sender)]); + for (let i = 0; i < voteBlock.receipts.length; i++) { + voteBlock.receipts[i].result.expectOk().expectBool(true); + } + + const executeBlock = passProposal(chain, accounts, PROPOSALS.CCIP_025); + executeBlock.receipts[0].result.expectOk().expectUint(1); + executeBlock.receipts[1].result.expectOk().expectUint(2); + executeBlock.receipts[2].result.expectOk().expectUint(3); // assert ccip025.isVoteActive().result.expectBool(false); - + const proposalInfo = { name: types.ascii("Extend Direct Execute Sunset Period 3"), link: types.ascii("https://github.com/citycoins/governance/blob/feat/add-ccip-025/ccips/ccip-025/ccip-025-extend-direct-execute-sunset-period-3.md"), @@ -192,13 +277,14 @@ Clarinet.test({ assertEquals(ccip025.getProposalInfo().result.expectSome().expectTuple(), proposalInfo); const votePeriod = ccip025.getVotePeriod().result.expectSome().expectTuple(); - assertEquals(votePeriod.startBlock, types.uint(0)); - assertEquals(votePeriod.endBlock, types.uint(executeBlock.height)); + assertEquals(votePeriod.startBlock, types.uint(8)); + assertEquals(votePeriod.endBlock, types.uint(executeBlock.height - 1)); const voteTotals = ccip025.getVoteTotals().result.expectSome().expectTuple(); - assertEquals(voteTotals.totals.totalAmountYes, types.uint(amountStacked)); - assertEquals(voteTotals.totals.totalVotesYes, types.uint(1)); - assertEquals(voteTotals.totals.totalAmountNo, types.uint(0)); - assertEquals(voteTotals.totals.totalVotesNo, types.uint(0)); + const totals = voteTotals.totals.expectTuple(); + assertEquals(totals.totalAmountYes, types.uint(amountStacked)); + assertEquals(totals.totalVotesYes, types.uint(1)); + assertEquals(totals.totalAmountNo, types.uint(0)); + assertEquals(totals.totalVotesNo, types.uint(0)); }, }); diff --git a/utils/common.ts b/utils/common.ts index b2df99a..58c8665 100644 --- a/utils/common.ts +++ b/utils/common.ts @@ -129,6 +129,7 @@ export const PROPOSALS = { TEST_CCIP022_TREASURY_REDEMPTION_NYC_004: ADDRESS.concat(".test-ccip022-treasury-redemption-nyc-004"), TEST_CCIP022_TREASURY_REDEMPTION_NYC_005: ADDRESS.concat(".test-ccip022-treasury-redemption-nyc-005"), TEST_CCIP024_MIAMICOIN_SIGNAL_VOTE_001: ADDRESS.concat(".test-ccip024-miamicoin-signal-vote-001"), + TEST_CCIP025_EXTEND_SUNSET_PERIOD_3_001: ADDRESS.concat(".test-ccip025-extend-sunset-period-3-001"), }; export const EXTERNAL = { @@ -249,7 +250,7 @@ export const nyc: CityData = { }; // parses an (ok ...) response into a JS object -export function parseClarityTuple(clarityString) { +export function parseClarityTuple(clarityString: string) { // Step 1: Remove the outer (ok ) and the closing parenthesis let jsonString = clarityString.replace("(ok ", "").replace(")", ""); From d328c8accdb1bab61583343dd98cec2f5e4e47e7 Mon Sep 17 00:00:00 2001 From: "Jason Schrader (aider)" Date: Tue, 29 Oct 2024 15:10:57 -0700 Subject: [PATCH 08/11] feat: add test for voting after proposal execution --- .../ccip025-extend-sunset-period-3.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/proposals/ccip025-extend-sunset-period-3.test.ts b/tests/proposals/ccip025-extend-sunset-period-3.test.ts index 4046d8b..35abe4e 100644 --- a/tests/proposals/ccip025-extend-sunset-period-3.test.ts +++ b/tests/proposals/ccip025-extend-sunset-period-3.test.ts @@ -223,6 +223,50 @@ Clarinet.test({ }, }); +Clarinet.test({ + name: "ccip-025: vote-on-proposal() fails with ERR_PROPOSAL_NOT_ACTIVE after execution", + fn(chain: Chain, accounts: Map) { + // arrange + const sender = accounts.get("deployer")!; + const user1 = accounts.get("wallet_1")!; + const ccd007CityStacking = new CCD007CityStacking(chain, sender, "ccd007-citycoin-stacking"); + const ccip025 = new CCIP025ExtendDirectExecuteSunsetPeriod(chain, sender); + + const amountStacked = 500; + const lockPeriod = 10; + + // progress the chain to avoid underflow in + // stacking reward cycle calculation + chain.mineEmptyBlockUntil(CCD007CityStacking.FIRST_STACKING_BLOCK); + + // initialize contracts + constructAndPassProposal(chain, accounts, PROPOSALS.TEST_CCIP025_EXTEND_SUNSET_PERIOD_3_001); + + // stack first cycle u1 + const stackingBlock = chain.mineBlock([ccd007CityStacking.stack(user1, mia.cityName, amountStacked, lockPeriod)]); + stackingBlock.receipts[0].result.expectOk().expectBool(true); + + // progress the chain to cycle 5 + chain.mineEmptyBlockUntil(CCD007CityStacking.REWARD_CYCLE_LENGTH * 6 + 10); + ccd007CityStacking.getCurrentRewardCycle().result.expectUint(5); + + // vote and execute proposal + const voteBlock = chain.mineBlock([ccip025.voteOnProposal(user1, true)]); + voteBlock.receipts[0].result.expectOk().expectBool(true); + + const executeBlock = passProposal(chain, accounts, PROPOSALS.CCIP_025); + executeBlock.receipts[0].result.expectOk().expectUint(1); + executeBlock.receipts[1].result.expectOk().expectUint(2); + executeBlock.receipts[2].result.expectOk().expectUint(3); + + // act + const block = chain.mineBlock([ccip025.voteOnProposal(user1, false)]); + + // assert + block.receipts[0].result.expectErr().expectUint(CCIP025ExtendDirectExecuteSunsetPeriod.ErrCode.ERR_PROPOSAL_NOT_ACTIVE); + }, +}); + Clarinet.test({ name: "ccip-025: read-only functions return expected values", fn(chain: Chain, accounts: Map) { From a23561bbdac5e55198a08e696a550b0f52296d2b Mon Sep 17 00:00:00 2001 From: "Jason Schrader (aider)" Date: Tue, 29 Oct 2024 15:15:45 -0700 Subject: [PATCH 09/11] feat: add test for changing vote on proposal --- .../ccip025-extend-sunset-period-3.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/proposals/ccip025-extend-sunset-period-3.test.ts b/tests/proposals/ccip025-extend-sunset-period-3.test.ts index 35abe4e..459b111 100644 --- a/tests/proposals/ccip025-extend-sunset-period-3.test.ts +++ b/tests/proposals/ccip025-extend-sunset-period-3.test.ts @@ -44,6 +44,61 @@ Clarinet.test({ }, }); +Clarinet.test({ + name: "ccip-025: vote-on-proposal() succeeds when user changes their vote", + fn(chain: Chain, accounts: Map) { + // arrange + const sender = accounts.get("deployer")!; + const user1 = accounts.get("wallet_1")!; + const ccd007CityStacking = new CCD007CityStacking(chain, sender, "ccd007-citycoin-stacking"); + const ccip025 = new CCIP025ExtendDirectExecuteSunsetPeriod(chain, sender); + + const amountStacked = 500; + const lockPeriod = 10; + + // progress the chain to avoid underflow in + // stacking reward cycle calculation + chain.mineEmptyBlockUntil(CCD007CityStacking.FIRST_STACKING_BLOCK); + + // initialize contracts + constructAndPassProposal(chain, accounts, PROPOSALS.TEST_CCIP025_EXTEND_SUNSET_PERIOD_3_001); + + // stack first cycle u1, last cycle u10 + const stackingBlock = chain.mineBlock([ccd007CityStacking.stack(user1, mia.cityName, amountStacked, lockPeriod)]); + stackingBlock.receipts[0].result.expectOk().expectBool(true); + + // progress the chain to cycle 5 + chain.mineEmptyBlockUntil(CCD007CityStacking.REWARD_CYCLE_LENGTH * 6 + 10); + ccd007CityStacking.getCurrentRewardCycle().result.expectUint(5); + + // first vote - yes + const firstVote = chain.mineBlock([ccip025.voteOnProposal(user1, true)]); + firstVote.receipts[0].result.expectOk().expectBool(true); + + // verify initial vote totals + let voteTotals = ccip025.getVoteTotals().result.expectSome().expectTuple(); + let totals = voteTotals.totals.expectTuple(); + assertEquals(totals.totalAmountYes, types.uint(amountStacked)); + assertEquals(totals.totalVotesYes, types.uint(1)); + assertEquals(totals.totalAmountNo, types.uint(0)); + assertEquals(totals.totalVotesNo, types.uint(0)); + + // act - change vote to no + const block = chain.mineBlock([ccip025.voteOnProposal(user1, false)]); + + // assert + block.receipts[0].result.expectOk().expectBool(true); + + // verify updated vote totals + voteTotals = ccip025.getVoteTotals().result.expectSome().expectTuple(); + totals = voteTotals.totals.expectTuple(); + assertEquals(totals.totalAmountYes, types.uint(0)); + assertEquals(totals.totalVotesYes, types.uint(0)); + assertEquals(totals.totalAmountNo, types.uint(amountStacked)); + assertEquals(totals.totalVotesNo, types.uint(1)); + }, +}); + Clarinet.test({ name: "ccip-025: execute() fails with ERR_VOTE_FAILED if there are more no than yes votes", fn(chain: Chain, accounts: Map) { From e5e8e57347287ae095aa0bdb958b9a8c314bb5e8 Mon Sep 17 00:00:00 2001 From: "Jason Schrader (aider)" Date: Tue, 29 Oct 2024 15:17:33 -0700 Subject: [PATCH 10/11] feat: add test to verify get-vote-period returns none before vote concludes --- .../ccip025-extend-sunset-period-3.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/proposals/ccip025-extend-sunset-period-3.test.ts b/tests/proposals/ccip025-extend-sunset-period-3.test.ts index 459b111..8533815 100644 --- a/tests/proposals/ccip025-extend-sunset-period-3.test.ts +++ b/tests/proposals/ccip025-extend-sunset-period-3.test.ts @@ -322,6 +322,24 @@ Clarinet.test({ }, }); +Clarinet.test({ + name: "ccip-025: get-vote-period returns none before vote concludes", + fn(chain: Chain, accounts: Map) { + // arrange + const sender = accounts.get("deployer")!; + const ccip025 = new CCIP025ExtendDirectExecuteSunsetPeriod(chain, sender); + + // initialize contracts + constructAndPassProposal(chain, accounts, PROPOSALS.TEST_CCIP025_EXTEND_SUNSET_PERIOD_3_001); + + // act + const votePeriod = ccip025.getVotePeriod().result; + + // assert + votePeriod.expectNone(); + }, +}); + Clarinet.test({ name: "ccip-025: read-only functions return expected values", fn(chain: Chain, accounts: Map) { From 58e0e532682dd148a7b8715dbd4431aeba01622f Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Wed, 30 Oct 2024 23:02:00 -0700 Subject: [PATCH 11/11] fix: add ccip025 hash and update sunset block height --- contracts/proposals/ccip025-extend-sunset-period-3.clar | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/proposals/ccip025-extend-sunset-period-3.clar b/contracts/proposals/ccip025-extend-sunset-period-3.clar index 6e9e927..fba4f88 100644 --- a/contracts/proposals/ccip025-extend-sunset-period-3.clar +++ b/contracts/proposals/ccip025-extend-sunset-period-3.clar @@ -21,11 +21,11 @@ (define-constant CCIP_025 { name: "Extend Direct Execute Sunset Period 3", link: "https://github.com/citycoins/governance/blob/feat/add-ccip-025/ccips/ccip-025/ccip-025-extend-direct-execute-sunset-period-3.md", - hash: "TBD", + hash: "1ec1aa1216f871b802a742532ed90d6f7843a545", }) (define-constant VOTE_SCALE_FACTOR (pow u10 u16)) ;; 16 decimal places -(define-constant SUNSET_BLOCK u199668) +(define-constant SUNSET_BLOCK u277428) ;; ~2 years ;; set city ID (define-constant MIA_ID (default-to u1 (contract-call? .ccd004-city-registry get-city-id "mia")))