@sage-protocol/cli 0.6.6 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -2395,9 +2395,54 @@ function register(program) {
|
|
|
2395
2395
|
ui.warn(`Could not preview proposal actions: ${previewError.message}`);
|
|
2396
2396
|
}
|
|
2397
2397
|
}
|
|
2398
|
+
|
|
2399
|
+
// Preflight: check proposal state before attempting queue
|
|
2400
|
+
const governorAddr = ctx.governor || process.env.GOV;
|
|
2401
|
+
if (governorAddr) {
|
|
2402
|
+
try {
|
|
2403
|
+
const { getProposalTiming, isReadyToQueue, getQueueNotReadyMessage } = require('../utils/gov-timing');
|
|
2404
|
+
const timing = await getProposalTiming({
|
|
2405
|
+
governorAddress: governorAddr,
|
|
2406
|
+
proposalId: id,
|
|
2407
|
+
provider,
|
|
2408
|
+
});
|
|
2409
|
+
if (!isReadyToQueue(timing)) {
|
|
2410
|
+
const subdaoCtx = subdao || ctx.subdao || options.subdao;
|
|
2411
|
+
const msg = getQueueNotReadyMessage(timing, id, subdaoCtx);
|
|
2412
|
+
ui.error(msg);
|
|
2413
|
+
process.exit(1);
|
|
2414
|
+
}
|
|
2415
|
+
} catch (preflightErr) {
|
|
2416
|
+
// Non-fatal - proceed and let queueProposal handle errors
|
|
2417
|
+
if (!process.env.SAGE_QUIET_JSON) {
|
|
2418
|
+
ui.warn(`Could not preflight proposal state: ${preflightErr.message}`);
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2398
2423
|
await manager.queueProposal(id);
|
|
2399
2424
|
} catch (error) {
|
|
2400
|
-
|
|
2425
|
+
// Try to provide better error context if queue fails
|
|
2426
|
+
let errorMsg = error.message;
|
|
2427
|
+
if (errorMsg.includes('vote not successful') || errorMsg.includes('GovernorUnexpectedProposalState')) {
|
|
2428
|
+
const governorAddr = ctx?.governor || process.env.GOV;
|
|
2429
|
+
if (governorAddr) {
|
|
2430
|
+
try {
|
|
2431
|
+
const { getProposalTiming, getQueueNotReadyMessage } = require('../utils/gov-timing');
|
|
2432
|
+
const provider = new (require('ethers').JsonRpcProvider)(process.env.RPC_URL || 'https://base-sepolia.publicnode.com');
|
|
2433
|
+
const timing = await getProposalTiming({
|
|
2434
|
+
governorAddress: governorAddr,
|
|
2435
|
+
proposalId: id,
|
|
2436
|
+
provider,
|
|
2437
|
+
});
|
|
2438
|
+
const subdaoCtx = subdao || ctx?.subdao || options?.subdao;
|
|
2439
|
+
errorMsg = getQueueNotReadyMessage(timing, id, subdaoCtx);
|
|
2440
|
+
} catch (_) {
|
|
2441
|
+
// Fall back to original error
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
ui.error('Failed to queue proposal:', errorMsg);
|
|
2401
2446
|
process.exit(1);
|
|
2402
2447
|
}
|
|
2403
2448
|
})
|
|
@@ -2799,6 +2844,21 @@ function register(program) {
|
|
|
2799
2844
|
await ui.transaction(`Casting vote: ${supportLabels[supportInt]}`, () => governor.castVote(proposalId, supportInt));
|
|
2800
2845
|
}
|
|
2801
2846
|
|
|
2847
|
+
// Show timing info after successful vote
|
|
2848
|
+
try {
|
|
2849
|
+
const { getProposalTiming, getPostVoteMessage } = require('../utils/gov-timing');
|
|
2850
|
+
const timing = await getProposalTiming({
|
|
2851
|
+
governorAddress: governorAddr,
|
|
2852
|
+
proposalId,
|
|
2853
|
+
provider: signer.provider,
|
|
2854
|
+
});
|
|
2855
|
+
const subdao = opts.subdao || process.env.WORKING_SUBDAO_ADDRESS;
|
|
2856
|
+
const timingMsg = getPostVoteMessage(timing, proposalId, subdao);
|
|
2857
|
+
ui.info(`\n${timingMsg}`);
|
|
2858
|
+
} catch (timingErr) {
|
|
2859
|
+
// Non-fatal - just skip timing display
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2802
2862
|
} catch (e) {
|
|
2803
2863
|
ui.error('Failed to cast vote:', e.message);
|
|
2804
2864
|
if (e.message?.includes('already voted')) {
|
|
@@ -206,6 +206,58 @@ function createProposalDiagnostics(overrides = {}) {
|
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
// GovernorUnexpectedProposalState(uint256,uint8,bytes32) = 0x31b75e4d (duplicate proposal)
|
|
210
|
+
if (errorSig === '0x31b75e4d' || message.includes('GovernorUnexpectedProposalState') || message.includes('unexpectedproposalstate')) {
|
|
211
|
+
logger.log('');
|
|
212
|
+
logger.log('❌ A proposal with the same content already exists.');
|
|
213
|
+
logger.log('');
|
|
214
|
+
|
|
215
|
+
// Compute the proposal ID and look up its state
|
|
216
|
+
try {
|
|
217
|
+
const descHash = ethers.keccak256(ethers.toUtf8Bytes(desc));
|
|
218
|
+
const proposalId = await governor.hashProposal(targets, values, calldatas, descHash);
|
|
219
|
+
const state = await governor.state(proposalId);
|
|
220
|
+
const stateNames = ['Pending', 'Active', 'Canceled', 'Defeated', 'Succeeded', 'Queued', 'Expired', 'Executed'];
|
|
221
|
+
const stateName = stateNames[Number(state)] || `Unknown (${state})`;
|
|
222
|
+
|
|
223
|
+
logger.log('📋 Existing proposal:');
|
|
224
|
+
logger.log(` ID: ${proposalId}`);
|
|
225
|
+
logger.log(` State: ${stateName}`);
|
|
226
|
+
|
|
227
|
+
// Get deadline if available
|
|
228
|
+
try {
|
|
229
|
+
const deadline = await governor.proposalDeadline(proposalId);
|
|
230
|
+
logger.log(` Deadline: block ${deadline}`);
|
|
231
|
+
} catch (_) { /* deadline not available */ }
|
|
232
|
+
|
|
233
|
+
logger.log('');
|
|
234
|
+
logger.log('💡 Next steps:');
|
|
235
|
+
|
|
236
|
+
const subdaoEnv = process.env.WORKING_SUBDAO_ADDRESS || process.env.SUBDAO || null;
|
|
237
|
+
const subdaoFlag = subdaoEnv ? ` --subdao ${subdaoEnv}` : '';
|
|
238
|
+
|
|
239
|
+
if (state === 0n || state === 1n) {
|
|
240
|
+
// Pending or Active - can vote
|
|
241
|
+
logger.log(` • Vote FOR: sage gov vote ${proposalId} 1${subdaoFlag}`);
|
|
242
|
+
logger.log(` • Check status: sage gov status ${proposalId}${subdaoFlag}`);
|
|
243
|
+
} else if (state === 5n) {
|
|
244
|
+
// Queued - ready to execute
|
|
245
|
+
logger.log(` • Execute: sage gov execute ${proposalId}${subdaoFlag}`);
|
|
246
|
+
} else if (state === 7n) {
|
|
247
|
+
// Already executed
|
|
248
|
+
logger.log(' • This proposal was already executed successfully.');
|
|
249
|
+
logger.log(' • If you need a new update, modify the description to create a unique proposal.');
|
|
250
|
+
} else {
|
|
251
|
+
logger.log(` • Check status: sage gov status ${proposalId}${subdaoFlag}`);
|
|
252
|
+
}
|
|
253
|
+
} catch (lookupErr) {
|
|
254
|
+
logger.log(' Could not look up existing proposal details.');
|
|
255
|
+
logger.log(' Use `sage proposals inbox` to find active proposals.');
|
|
256
|
+
}
|
|
257
|
+
logger.log('');
|
|
258
|
+
return { gasEstimated: false, proceed: false, reason: 'duplicateProposal' };
|
|
259
|
+
}
|
|
260
|
+
|
|
209
261
|
if (message.toLowerCase().includes('cooldown')) {
|
|
210
262
|
try {
|
|
211
263
|
const lastTs = await governor.lastProposalTimestamp(proposerAddress).catch(() => 0n);
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governance timing utilities for displaying proposal voting period info
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { ethers } = require('ethers');
|
|
6
|
+
|
|
7
|
+
const GOVERNOR_ABI = [
|
|
8
|
+
'function state(uint256) view returns (uint8)',
|
|
9
|
+
'function proposalSnapshot(uint256) view returns (uint256)',
|
|
10
|
+
'function proposalDeadline(uint256) view returns (uint256)',
|
|
11
|
+
'function proposalEta(uint256) view returns (uint256)',
|
|
12
|
+
'function votingDelay() view returns (uint256)',
|
|
13
|
+
'function votingPeriod() view returns (uint256)',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const STATE_NAMES = ['Pending', 'Active', 'Canceled', 'Defeated', 'Succeeded', 'Queued', 'Expired', 'Executed'];
|
|
17
|
+
|
|
18
|
+
// Base ~2s blocks
|
|
19
|
+
const BLOCKS_PER_MIN = 30;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get proposal timing info from governor
|
|
23
|
+
* @param {Object} params
|
|
24
|
+
* @param {string} params.governorAddress - Governor contract address
|
|
25
|
+
* @param {string|bigint} params.proposalId - Proposal ID
|
|
26
|
+
* @param {ethers.Provider} params.provider - Ethers provider
|
|
27
|
+
* @returns {Promise<Object>} Timing information
|
|
28
|
+
*/
|
|
29
|
+
async function getProposalTiming({ governorAddress, proposalId, provider }) {
|
|
30
|
+
const governor = new ethers.Contract(governorAddress, GOVERNOR_ABI, provider);
|
|
31
|
+
const currentBlock = await provider.getBlockNumber();
|
|
32
|
+
|
|
33
|
+
const [state, snapshot, deadline, eta] = await Promise.all([
|
|
34
|
+
governor.state(proposalId).catch(() => 0),
|
|
35
|
+
governor.proposalSnapshot(proposalId).catch(() => 0n),
|
|
36
|
+
governor.proposalDeadline(proposalId).catch(() => 0n),
|
|
37
|
+
governor.proposalEta(proposalId).catch(() => 0n),
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const stateNum = Number(state);
|
|
41
|
+
const stateName = STATE_NAMES[stateNum] || 'Unknown';
|
|
42
|
+
const snapshotNum = Number(snapshot);
|
|
43
|
+
const deadlineNum = Number(deadline);
|
|
44
|
+
const etaNum = Number(eta);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
state: stateNum,
|
|
48
|
+
stateName,
|
|
49
|
+
currentBlock,
|
|
50
|
+
snapshot: snapshotNum,
|
|
51
|
+
deadline: deadlineNum,
|
|
52
|
+
eta: etaNum > 0 ? etaNum : null,
|
|
53
|
+
blocksPerMin: BLOCKS_PER_MIN,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Format blocks remaining as human-readable time
|
|
59
|
+
* @param {number} blocks - Number of blocks
|
|
60
|
+
* @returns {string} Formatted time string
|
|
61
|
+
*/
|
|
62
|
+
function formatBlocksAsTime(blocks) {
|
|
63
|
+
if (blocks <= 0) return '0 minutes';
|
|
64
|
+
const mins = Math.ceil(blocks / BLOCKS_PER_MIN);
|
|
65
|
+
if (mins < 60) return `~${mins} minute${mins !== 1 ? 's' : ''}`;
|
|
66
|
+
const hours = Math.floor(mins / 60);
|
|
67
|
+
const remainingMins = mins % 60;
|
|
68
|
+
if (hours < 24) {
|
|
69
|
+
return remainingMins > 0
|
|
70
|
+
? `~${hours}h ${remainingMins}m`
|
|
71
|
+
: `~${hours} hour${hours !== 1 ? 's' : ''}`;
|
|
72
|
+
}
|
|
73
|
+
const days = Math.floor(hours / 24);
|
|
74
|
+
const remainingHours = hours % 24;
|
|
75
|
+
return `~${days}d ${remainingHours}h`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get timing message after successful vote
|
|
80
|
+
* @param {Object} timing - Timing info from getProposalTiming
|
|
81
|
+
* @param {string} proposalId - Proposal ID
|
|
82
|
+
* @param {string} subdao - SubDAO address (for command hints)
|
|
83
|
+
* @returns {string} Message to display after voting
|
|
84
|
+
*/
|
|
85
|
+
function getPostVoteMessage(timing, proposalId, subdao) {
|
|
86
|
+
const { state, stateName, currentBlock, deadline } = timing;
|
|
87
|
+
|
|
88
|
+
if (state !== 1) { // Not Active
|
|
89
|
+
return `Proposal is in ${stateName} state.`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const blocksRemaining = deadline - currentBlock;
|
|
93
|
+
if (blocksRemaining <= 0) {
|
|
94
|
+
return `Voting period ended. Check proposal state: sage governance status ${proposalId}${subdao ? ` --subdao ${subdao}` : ''}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const timeStr = formatBlocksAsTime(blocksRemaining);
|
|
98
|
+
return `Voting period ends in ${timeStr} (${blocksRemaining} blocks, at block ${deadline}).\nAfter voting ends, queue with: sage governance queue ${proposalId}${subdao ? ` --subdao ${subdao}` : ''} --yes`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get message when queue fails because voting is still active
|
|
103
|
+
* @param {Object} timing - Timing info from getProposalTiming
|
|
104
|
+
* @param {string} proposalId - Proposal ID
|
|
105
|
+
* @param {string} subdao - SubDAO address (for command hints)
|
|
106
|
+
* @returns {string} Helpful error message
|
|
107
|
+
*/
|
|
108
|
+
function getQueueNotReadyMessage(timing, proposalId, subdao) {
|
|
109
|
+
const { state, stateName, currentBlock, deadline } = timing;
|
|
110
|
+
|
|
111
|
+
if (state === 1) { // Active
|
|
112
|
+
const blocksRemaining = deadline - currentBlock;
|
|
113
|
+
const timeStr = formatBlocksAsTime(blocksRemaining);
|
|
114
|
+
return `Proposal is still in Active state (voting in progress).\n` +
|
|
115
|
+
`Voting ends in ${timeStr} (${blocksRemaining} blocks, at block ${deadline}).\n` +
|
|
116
|
+
`Wait for voting to end, then try: sage governance queue ${proposalId}${subdao ? ` --subdao ${subdao}` : ''} --yes`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (state === 0) { // Pending
|
|
120
|
+
return `Proposal is in Pending state (voting hasn't started yet).\n` +
|
|
121
|
+
`Check status: sage governance status ${proposalId}${subdao ? ` --subdao ${subdao}` : ''}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (state === 3) { // Defeated
|
|
125
|
+
return `Proposal was Defeated (did not reach quorum or was voted down). Cannot queue.`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (state === 2) { // Canceled
|
|
129
|
+
return `Proposal was Canceled. Cannot queue.`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (state === 5) { // Queued
|
|
133
|
+
const { eta } = timing;
|
|
134
|
+
if (eta) {
|
|
135
|
+
const now = Math.floor(Date.now() / 1000);
|
|
136
|
+
const secsRemaining = eta - now;
|
|
137
|
+
if (secsRemaining > 0) {
|
|
138
|
+
const minsRemaining = Math.ceil(secsRemaining / 60);
|
|
139
|
+
return `Proposal is already Queued.\n` +
|
|
140
|
+
`Executable in ~${minsRemaining} minute${minsRemaining !== 1 ? 's' : ''} (ETA: ${new Date(eta * 1000).toISOString()}).\n` +
|
|
141
|
+
`Execute with: sage governance execute ${proposalId}${subdao ? ` --subdao ${subdao}` : ''} --yes`;
|
|
142
|
+
}
|
|
143
|
+
return `Proposal is already Queued and ready to execute.\n` +
|
|
144
|
+
`Execute with: sage governance execute ${proposalId}${subdao ? ` --subdao ${subdao}` : ''} --yes`;
|
|
145
|
+
}
|
|
146
|
+
return `Proposal is already Queued. Execute with: sage governance execute ${proposalId}${subdao ? ` --subdao ${subdao}` : ''} --yes`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (state === 6) { // Expired
|
|
150
|
+
return `Proposal Expired (was queued but not executed in time). Cannot queue.`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (state === 7) { // Executed
|
|
154
|
+
return `Proposal was already Executed.`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return `Proposal is in ${stateName} state (${state}). Cannot queue.`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Check if proposal is ready to queue (Succeeded state)
|
|
162
|
+
* @param {Object} timing - Timing info from getProposalTiming
|
|
163
|
+
* @returns {boolean}
|
|
164
|
+
*/
|
|
165
|
+
function isReadyToQueue(timing) {
|
|
166
|
+
return timing.state === 4; // Succeeded
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check if proposal is ready to execute (Queued and ETA passed)
|
|
171
|
+
* @param {Object} timing - Timing info from getProposalTiming
|
|
172
|
+
* @returns {boolean}
|
|
173
|
+
*/
|
|
174
|
+
function isReadyToExecute(timing) {
|
|
175
|
+
if (timing.state !== 5) return false; // Must be Queued
|
|
176
|
+
if (!timing.eta) return true; // No ETA, assume ready
|
|
177
|
+
const now = Math.floor(Date.now() / 1000);
|
|
178
|
+
return now >= timing.eta;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
getProposalTiming,
|
|
183
|
+
formatBlocksAsTime,
|
|
184
|
+
getPostVoteMessage,
|
|
185
|
+
getQueueNotReadyMessage,
|
|
186
|
+
isReadyToQueue,
|
|
187
|
+
isReadyToExecute,
|
|
188
|
+
STATE_NAMES,
|
|
189
|
+
BLOCKS_PER_MIN,
|
|
190
|
+
};
|