@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
- ui.error('Failed to queue proposal:', error.message);
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sage-protocol/cli",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "Sage Protocol CLI for managing AI prompt libraries",
5
5
  "bin": {
6
6
  "sage": "./bin/sage.js"