diff --git a/Clarinet-legacy.toml b/Clarinet-legacy.toml index 7e95afb..fa7a4c1 100644 --- a/Clarinet-legacy.toml +++ b/Clarinet-legacy.toml @@ -179,6 +179,11 @@ path = "contracts/proposals/ccip022-treasury-redemption-nyc.clar" clarity_version = 2 epoch = 2.4 +[contracts.ccip024-miamicoin-signal-vote] +path = "contracts/proposals/ccip024-miamicoin-signal-vote.clar" +clarity_version = 2 +epoch = 2.4 + # CITYCOINS PROTOCOL TRAITS [contracts.extension-trait] @@ -555,6 +560,11 @@ path = "tests/contracts/proposals/test-ccip022-treasury-redemption-nyc-005.clar" clarity_version = 2 epoch = 2.4 +[contracts.test-ccip024-miamicoin-signal-vote-001] +path = "tests/contracts/proposals/test-ccip024-miamicoin-signal-vote-001.clar" +clarity_version = 2 +epoch = 2.4 + [repl] costs_version = 2 parser_version = 2 diff --git a/Clarinet.toml b/Clarinet.toml index 779a6a6..67ce4f0 100644 --- a/Clarinet.toml +++ b/Clarinet.toml @@ -191,10 +191,16 @@ path = "contracts/proposals/ccip022-treasury-redemption-nyc.clar" clarity_version = 2 epoch = 2.4 +[contracts.ccip024-miamicoin-signal-vote] +path = "contracts/proposals/ccip024-miamicoin-signal-vote.clar" +clarity_version = 2 +epoch = 2.4 + [contracts.ccip019-pox-4-stacking] path = "contracts/proposals/ccip019-pox-4-stacking.clar" clarity_version = 2 epoch = 2.5 + # CITYCOINS PROTOCOL TRAITS [contracts.extension-trait] @@ -571,6 +577,11 @@ path = "tests/contracts/proposals/test-ccip022-treasury-redemption-nyc-005.clar" clarity_version = 2 epoch = 2.4 +[contracts.test-ccip024-miamicoin-signal-vote-001] +path = "tests/contracts/proposals/test-ccip024-miamicoin-signal-vote-001.clar" +clarity_version = 2 +epoch = 2.4 + [repl] costs_version = 2 parser_version = 2 diff --git a/contracts/proposals/ccip024-miamicoin-signal-vote.clar b/contracts/proposals/ccip024-miamicoin-signal-vote.clar new file mode 100644 index 0000000..617be47 --- /dev/null +++ b/contracts/proposals/ccip024-miamicoin-signal-vote.clar @@ -0,0 +1,252 @@ +;; TRAITS + +(impl-trait .proposal-trait.proposal-trait) +(impl-trait .ccip015-trait.ccip015-trait) + +;; ERRORS + +(define-constant ERR_PANIC (err u24000)) +(define-constant ERR_SAVING_VOTE (err u24001)) +(define-constant ERR_VOTED_ALREADY (err u24002)) +(define-constant ERR_NOTHING_STACKED (err u24003)) +(define-constant ERR_USER_NOT_FOUND (err u24004)) +(define-constant ERR_PROPOSAL_NOT_ACTIVE (err u24005)) +(define-constant ERR_PROPOSAL_STILL_ACTIVE (err u24006)) +(define-constant ERR_VOTE_FAILED (err u24007)) + +;; CONSTANTS + +(define-constant SELF (as-contract tx-sender)) +(define-constant CCIP_024 { + name: "MiamiCoin Community Signal Vote", + link: "https://github.com/citycoins/governance/blob/feat/add-ccip-024/ccips/ccip-024/ccip-024-miamicoin-community-signal-vote.md", + hash: "2a32c503f434a73aa4d54555fc222b53755fa665", +}) + +(define-constant VOTE_SCALE_FACTOR (pow u10 u16)) ;; 16 decimal places +(define-constant VOTE_LENGTH u2016) ;; approximately 2 weeks in Bitcoin blocks + +;; set city ID +(define-constant MIA_ID (default-to u1 (contract-call? .ccd004-city-registry get-city-id "mia"))) + +;; DATA VARS + +;; vote block heights +;; MAINNET: start the vote when deployed +;; (define-data-var voteStart uint block-height) +(define-data-var voteStart uint (+ block-height u12600)) +;; MAINNET: end the vote after defined period +;; (define-data-var voteEnd uint (+ block-height VOTE_LENGTH)) +(define-data-var voteEnd uint (+ block-height VOTE_LENGTH u12600)) + +;; 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)) + ;; no action to execute, this is a signal vote + (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-024", + 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-024", + payload: (get-voter-info voterId) + }) + (ok true) + ) + ) + ) +) + +;; READ ONLY FUNCTIONS + +(define-read-only (is-executable) + ;; no action to execute, this is a signal vote + (ok true) +) + +(define-read-only (is-vote-active) + (if (and (> block-height (var-get voteStart)) (<= block-height (var-get voteEnd))) + true + false +)) + +(define-read-only (get-proposal-info) + (some CCIP_024) +) + +(define-read-only (get-vote-period) + (some { + startBlock: (var-get voteStart), + endBlock: (var-get voteEnd), + length: VOTE_LENGTH + }) +) + +(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) +) diff --git a/models/proposals/ccip024-miamicoin-signal-vote.model.ts b/models/proposals/ccip024-miamicoin-signal-vote.model.ts new file mode 100644 index 0000000..47c68f4 --- /dev/null +++ b/models/proposals/ccip024-miamicoin-signal-vote.model.ts @@ -0,0 +1,78 @@ +import { Chain, Account, Tx, types, ReadOnlyFn } from "../../utils/deps.ts"; + +enum ErrCode { + ERR_PANIC = 24000, + ERR_SAVING_VOTE, + ERR_VOTED_ALREADY, + ERR_NOTHING_STACKED, + ERR_USER_NOT_FOUND, + ERR_PROPOSAL_NOT_ACTIVE, + ERR_PROPOSAL_STILL_ACTIVE, + ERR_VOTE_FAILED, +} + +export class CCIP024MiamiCoinSignalVote { + name = "ccip024-miamicoin-signal-vote"; + 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-ccip024-miamicoin-signal-vote-001.clar b/tests/contracts/proposals/test-ccip024-miamicoin-signal-vote-001.clar new file mode 100644 index 0000000..b0c9a66 --- /dev/null +++ b/tests/contracts/proposals/test-ccip024-miamicoin-signal-vote-001.clar @@ -0,0 +1,36 @@ +;; Title: Test Proposal for CCIP-024 +;; Version: 1.0.0 +;; Synopsis: Test proposal for CCIP-024 MiamiCoin Community Signal Vote +;; Description: +;; Sets up everything required for CCIP-024 + +(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/ccip024-miamicoin-signal-vote.test.ts b/tests/proposals/ccip024-miamicoin-signal-vote.test.ts new file mode 100644 index 0000000..2ca2f87 --- /dev/null +++ b/tests/proposals/ccip024-miamicoin-signal-vote.test.ts @@ -0,0 +1,150 @@ +import { Account, Clarinet, Chain, types } from "../../utils/deps.ts"; +import { constructAndPassProposal, PROPOSALS, mia } from "../../utils/common.ts"; +import { CCD007CityStacking } from "../../models/extensions/ccd007-citycoin-stacking.model.ts"; +import { CCIP024MiamiCoinSignalVote } from "../../models/proposals/ccip024-miamicoin-signal-vote.model.ts"; + +Clarinet.test({ + name: "ccip-024: vote-on-proposal() succeeds for eligible voters", + fn(chain: Chain, accounts: Map) { + const sender = accounts.get("deployer")!; + const user1 = accounts.get("wallet_1")!; + const ccd007CityStacking = new CCD007CityStacking(chain, sender, "ccd007-citycoin-stacking"); + const ccip024 = new CCIP024MiamiCoinSignalVote(chain, sender); + + // Initialize contracts and stack + chain.mineEmptyBlockUntil(CCD007CityStacking.FIRST_STACKING_BLOCK); + constructAndPassProposal(chain, accounts, PROPOSALS.TEST_CCIP024_MIAMICOIN_SIGNAL_VOTE_001); + chain.mineBlock([ccd007CityStacking.stack(user1, mia.cityName, 500, 10)]); + chain.mineEmptyBlockUntil(CCD007CityStacking.REWARD_CYCLE_LENGTH * 6 + 10); + + // Act & Assert + const receipt = chain.mineBlock([ccip024.voteOnProposal(user1, true)]).receipts[0]; + receipt.result.expectOk().expectBool(true); + }, +}); + +Clarinet.test({ + name: "ccip-024: vote-on-proposal() succeeds if vote is changed", + fn(chain: Chain, accounts: Map) { + const sender = accounts.get("deployer")!; + const user1 = accounts.get("wallet_1")!; + const ccd007CityStacking = new CCD007CityStacking(chain, sender, "ccd007-citycoin-stacking"); + const ccip024 = new CCIP024MiamiCoinSignalVote(chain, sender); + + // Initialize contracts and stack + chain.mineEmptyBlockUntil(CCD007CityStacking.FIRST_STACKING_BLOCK); + constructAndPassProposal(chain, accounts, PROPOSALS.TEST_CCIP024_MIAMICOIN_SIGNAL_VOTE_001); + chain.mineBlock([ccd007CityStacking.stack(user1, mia.cityName, 500, 10)]); + chain.mineEmptyBlockUntil(CCD007CityStacking.REWARD_CYCLE_LENGTH * 6 + 10); + const receipt = chain.mineBlock([ccip024.voteOnProposal(user1, true)]).receipts[0]; + receipt.result.expectOk().expectBool(true); + + // Act & Assert + const receiptReverse = chain.mineBlock([ccip024.voteOnProposal(user1, false)]).receipts[0]; + receiptReverse.result.expectOk().expectBool(true); + }, +}); + +Clarinet.test({ + name: "ccip-024: vote-on-proposal() fails for ineligible voters", + fn(chain: Chain, accounts: Map) { + const sender = accounts.get("deployer")!; + const user1 = accounts.get("wallet_1")!; + const ccip024 = new CCIP024MiamiCoinSignalVote(chain, sender); + + constructAndPassProposal(chain, accounts, PROPOSALS.TEST_CCIP024_MIAMICOIN_SIGNAL_VOTE_001); + + const receipt = chain.mineBlock([ccip024.voteOnProposal(user1, true)]).receipts[0]; + receipt.result.expectErr().expectUint(CCIP024MiamiCoinSignalVote.ErrCode.ERR_USER_NOT_FOUND); + }, +}); + +Clarinet.test({ + name: "ccip-024: vote-on-proposal() fails after voting period ends", + fn(chain: Chain, accounts: Map) { + const sender = accounts.get("deployer")!; + const user1 = accounts.get("wallet_1")!; + const ccd007CityStacking = new CCD007CityStacking(chain, sender, "ccd007-citycoin-stacking"); + const ccip024 = new CCIP024MiamiCoinSignalVote(chain, sender); + + chain.mineEmptyBlockUntil(CCD007CityStacking.FIRST_STACKING_BLOCK); + constructAndPassProposal(chain, accounts, PROPOSALS.TEST_CCIP024_MIAMICOIN_SIGNAL_VOTE_001); + chain.mineBlock([ccd007CityStacking.stack(user1, mia.cityName, 500, 10)]); + chain.mineEmptyBlockUntil(CCD007CityStacking.REWARD_CYCLE_LENGTH * 7 + 10); + + const receipt = chain.mineBlock([ccip024.voteOnProposal(user1, true)]).receipts[0]; + receipt.result.expectErr().expectUint(CCIP024MiamiCoinSignalVote.ErrCode.ERR_PROPOSAL_NOT_ACTIVE); + }, +}); + +Clarinet.test({ + name: "ccip-024: read-only functions return expected values", + fn(chain: Chain, accounts: Map) { + 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 ccip024 = new CCIP024MiamiCoinSignalVote(chain, sender); + const voteLength = 2016; + + // Setup + chain.mineEmptyBlockUntil(CCD007CityStacking.FIRST_STACKING_BLOCK); + constructAndPassProposal(chain, accounts, PROPOSALS.TEST_CCIP024_MIAMICOIN_SIGNAL_VOTE_001); + chain.mineBlock([ccd007CityStacking.stack(user1, mia.cityName, 500, 10), ccd007CityStacking.stack(user2, mia.cityName, 300, 10)]); + ccip024.isVoteActive().result.expectBool(false); + chain.mineEmptyBlockUntil(CCD007CityStacking.REWARD_CYCLE_LENGTH * 6 + 10); + + // Vote + const voteBlock = chain.mineBlock([ccip024.voteOnProposal(user1, true), ccip024.voteOnProposal(user2, false)]); + + // Assert + ccip024 + .getVoteTotals() + .result.expectSome() + .expectTuple({ + totalVotesYes: types.uint(1), + totalVotesNo: types.uint(1), + }); + + ccip024 + .getVoteTotalMia() + .result.expectSome() + .expectTuple({ + totalAmountYes: types.uint(500), + totalAmountNo: types.uint(300), + totalVotesYes: types.uint(1), + totalVotesNo: types.uint(1), + }); + + ccip024 + .getVoterInfo(1) + .result.expectSome() + .expectTuple({ + vote: types.bool(true), + mia: types.uint(500), + }); + + ccip024.isVoteActive().result.expectBool(true); + + const voteInfo = ccip024.getVotePeriod().result.expectSome().expectTuple(); + voteInfo.length.expectUint(voteLength); + + chain.mineEmptyBlockUntil(voteBlock.height + voteLength); + ccip024.isVoteActive().result.expectBool(false); + }, +}); + +Clarinet.test({ + name: "ccip-024: execute() and is-executable() always succeed", + fn(chain: Chain, accounts: Map) { + const sender = accounts.get("deployer")!; + const ccip024 = new CCIP024MiamiCoinSignalVote(chain, sender); + + constructAndPassProposal(chain, accounts, PROPOSALS.TEST_CCIP024_MIAMICOIN_SIGNAL_VOTE_001); + + ccip024.isExecutable().result.expectOk().expectBool(true); + + const receipt = chain.mineBlock([ccip024.execute(sender)]).receipts[0]; + receipt.result.expectOk().expectBool(true); + }, +}); diff --git a/utils/common.ts b/utils/common.ts index e302729..d9a2d88 100644 --- a/utils/common.ts +++ b/utils/common.ts @@ -44,6 +44,7 @@ export const PROPOSALS = { CCIP_020: ADDRESS.concat(".ccip020-graceful-protocol-shutdown"), 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"), 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"), @@ -126,6 +127,7 @@ export const PROPOSALS = { TEST_CCIP022_TREASURY_REDEMPTION_NYC_003: ADDRESS.concat(".test-ccip022-treasury-redemption-nyc-003"), 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"), }; export const EXTERNAL = {