@sage-protocol/cli 0.6.5 → 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')) {
@@ -479,7 +479,7 @@ function register(program) {
479
479
  )
480
480
  .addCommand(
481
481
  new Command('delegate-self')
482
- .description('Delegate voting power to yourself')
482
+ .description('Delegate voting power to yourself (enables you to vote)')
483
483
  .action(async () => {
484
484
  try {
485
485
  const manager = new SXXXManager();
@@ -491,6 +491,50 @@ function register(program) {
491
491
  }
492
492
  })
493
493
  )
494
+ .addCommand(
495
+ new Command('delegate')
496
+ .description('Delegate voting power to an address (use delegate-self to take it back)')
497
+ .argument('<address>', 'Address to delegate voting power to')
498
+ .option('-y, --yes', 'Skip confirmation prompt')
499
+ .option('--confirm', 'Confirm delegation (required without -y)')
500
+ .action(async (address, opts) => {
501
+ try {
502
+ const manager = new SXXXManager();
503
+ await manager.initialize();
504
+
505
+ // Get user's own address for comparison
506
+ let userAddress;
507
+ if (manager.signer) {
508
+ userAddress = await manager.signer.getAddress();
509
+ } else {
510
+ const WalletManager = require('../wallet-manager');
511
+ const walletManager = new WalletManager();
512
+ await walletManager.connect();
513
+ userAddress = walletManager.getAccount();
514
+ }
515
+
516
+ // If delegating to self, no confirmation needed
517
+ const isSelfDelegation = address.toLowerCase() === userAddress.toLowerCase();
518
+
519
+ if (!isSelfDelegation && !opts.yes && !opts.confirm) {
520
+ console.log('\n⚠️ WARNING: You are about to delegate your voting power to another address.');
521
+ console.log(` Delegatee: ${address}`);
522
+ console.log(' This means THEY will vote on your behalf, not you.\n');
523
+ console.log('To proceed, run one of:');
524
+ console.log(` sage sxxx delegate ${address} --confirm`);
525
+ console.log(` sage sxxx delegate ${address} -y\n`);
526
+ console.log('To vote yourself instead:');
527
+ console.log(' sage sxxx delegate-self\n');
528
+ process.exit(1);
529
+ }
530
+
531
+ await manager.delegateTo(address);
532
+ printSuggestions('sxxx:delegate');
533
+ } catch (error) {
534
+ handleCLIError('sxxx:delegate', error);
535
+ }
536
+ })
537
+ )
494
538
  .addCommand(
495
539
  new Command('delegation')
496
540
  .description('Check vote delegation status')
@@ -498,39 +498,68 @@ class PrivyAuthWalletManager {
498
498
  .loader { border: 3px solid #f3f3f3; border-top: 3px solid #000; border-radius: 50%; width: 20px; height: 20px; animation: spin 1s linear infinite; margin: 0 auto; }
499
499
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
500
500
  #error-display { position: fixed; top: 0; left: 0; right: 0; background: #fee2e2; color: #dc2626; padding: 1rem; display: none; z-index: 1000; }
501
- /* Fix Privy modal CSS issues from esm.sh bundling */
502
- /* Target Privy modal by common patterns */
501
+
502
+ /* Privy CSS variable overrides for proper modal sizing */
503
+ :root {
504
+ --privy-border-radius-sm: 4px;
505
+ --privy-border-radius-md: 8px;
506
+ --privy-border-radius-lg: 12px;
507
+ --privy-color-background: #ffffff;
508
+ --privy-color-foreground: #1a1a1a;
509
+ --privy-color-accent: #000000;
510
+ }
511
+
512
+ /* Fix Privy modal height and positioning */
513
+ [data-privy-dialog],
514
+ [id*="privy-modal"],
515
+ [class*="privy-modal"],
516
+ div[role="dialog"] {
517
+ max-height: 90vh !important;
518
+ overflow-y: auto !important;
519
+ }
520
+
521
+ /* Ensure modal content has proper height */
522
+ [data-privy-dialog] > div,
523
+ [id*="privy-modal"] > div,
524
+ div[role="dialog"] > div {
525
+ min-height: 400px !important;
526
+ max-height: 85vh !important;
527
+ }
528
+
529
+ /* Fix iframe sizing issues */
530
+ [data-privy-dialog] iframe,
531
+ [id*="privy"] iframe,
532
+ div[role="dialog"] iframe {
533
+ min-height: 500px !important;
534
+ width: 100% !important;
535
+ border: none !important;
536
+ }
537
+
538
+ /* Fix input field styling */
503
539
  [id*="privy"] input,
504
540
  [class*="privy"] input,
505
- [class*="Privy"] input,
506
541
  div[role="dialog"] input {
507
- padding-right: 80px !important;
542
+ padding: 12px 16px !important;
508
543
  box-sizing: border-box !important;
544
+ font-size: 16px !important;
545
+ width: 100% !important;
509
546
  }
510
- /* Hide the submit button text that appears inline */
511
- [id*="privy"] button[type="submit"],
512
- [class*="privy"] button[type="submit"],
513
- [class*="Privy"] button[type="submit"],
514
- div[role="dialog"] button[type="submit"] {
515
- position: absolute !important;
516
- right: 12px !important;
517
- font-size: 0 !important;
518
- background: transparent !important;
519
- border: none !important;
520
- padding: 8px !important;
521
- }
522
- /* But keep the icon/arrow visible */
523
- [id*="privy"] button[type="submit"] svg,
524
- [class*="privy"] button[type="submit"] svg,
525
- div[role="dialog"] button[type="submit"] svg {
526
- width: 20px !important;
527
- height: 20px !important;
547
+
548
+ /* Fix button positioning */
549
+ [id*="privy"] button,
550
+ [class*="privy"] button,
551
+ div[role="dialog"] button {
552
+ padding: 12px 24px !important;
553
+ font-size: 14px !important;
528
554
  }
529
- /* Ensure forms have relative positioning */
555
+
556
+ /* Ensure forms have proper layout */
530
557
  [id*="privy"] form,
531
558
  [class*="privy"] form,
532
559
  div[role="dialog"] form {
533
- position: relative !important;
560
+ display: flex !important;
561
+ flex-direction: column !important;
562
+ gap: 12px !important;
534
563
  }
535
564
  </style>
536
565
  <script>
@@ -552,7 +581,7 @@ class PrivyAuthWalletManager {
552
581
  "react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime",
553
582
  "react-dom": "https://esm.sh/react-dom@18.2.0",
554
583
  "react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
555
- "@privy-io/react-auth": "https://esm.sh/@privy-io/react-auth@1.73.1?external=react,react-dom"
584
+ "@privy-io/react-auth": "https://esm.sh/@privy-io/react-auth@2.4.0?external=react,react-dom"
556
585
  }
557
586
  }
558
587
  </script>
@@ -447,16 +447,25 @@ class GovModeDetector {
447
447
  const proposerRole = await tl.PROPOSER_ROLE();
448
448
  const hasGovProposer = out.governor ? await tl.hasRole(proposerRole, out.governor) : false;
449
449
 
450
- if (out.governanceKind === 'TOKEN' && hasGovProposer) {
450
+ // If Governor has PROPOSER_ROLE on Timelock, route via governor-proposal
451
+ // This is the primary signal - governanceKind may be unknown if subdao context is missing
452
+ if (hasGovProposer) {
451
453
  out.route = 'governor-proposal';
452
- out.rationale.push('route: token governance + governor is proposer on timelock');
453
- } else {
454
- out.route = 'timelock-schedule';
455
- if (out.governanceKind === 'TOKEN' && !hasGovProposer) {
456
- out.rationale.push('route: token governance but governor lacks PROPOSER_ROLE (misconfigured)');
454
+ out.governorIsProposer = true;
455
+ if (out.governanceKind === 'TOKEN') {
456
+ out.rationale.push('route: token governance + governor is proposer on timelock');
457
457
  } else {
458
- out.rationale.push('route: schedule/execute directly via timelock');
458
+ // Governor has PROPOSER_ROLE but governanceKind wasn't detected - infer token governance
459
+ out.rationale.push('route: governor has PROPOSER_ROLE on timelock (inferred token governance)');
460
+ if (!out.governanceKind) out.governanceKind = 'TOKEN';
459
461
  }
462
+ } else if (out.governanceKind === 'OPERATOR' || !out.governanceKind) {
463
+ out.route = 'timelock-schedule';
464
+ out.rationale.push('route: schedule/execute directly via timelock (operator mode)');
465
+ } else {
466
+ // TOKEN governance but governor lacks PROPOSER_ROLE - misconfigured
467
+ out.route = 'timelock-schedule';
468
+ out.rationale.push('route: token governance but governor lacks PROPOSER_ROLE (misconfigured)');
460
469
  }
461
470
  return;
462
471
  } catch (_) {
@@ -46,7 +46,7 @@ function createOperatorScheduler(overrides = {}) {
46
46
 
47
47
  const timelock = new ethersLib.Contract(timelockAddress, timelockAbi, signer);
48
48
 
49
- logger.log('\n⚠️ Operator mode detected: Governor is not a Timelock proposer.');
49
+ logger.log('\n⚠️ Operator mode: scheduling directly via Timelock (Governor lacks PROPOSER_ROLE or is not configured).');
50
50
 
51
51
  const proposerRole = await timelock.PROPOSER_ROLE();
52
52
  const hasRole = await timelock.hasRole(proposerRole, proposerAddress);
@@ -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);
@@ -123,8 +123,69 @@ function createProposalSubmitter(overrides = {}) {
123
123
  return { proposalId, transaction: tx, receipt };
124
124
  }
125
125
 
126
- function handleKnownError(error, { govAddress, proposalStake, sxxxAddress, rpcUrl }) {
126
+ const PROPOSAL_STATES = ['Pending', 'Active', 'Canceled', 'Defeated', 'Succeeded', 'Queued', 'Expired', 'Executed'];
127
+
128
+ function getProposalStateName(stateNum) {
129
+ return PROPOSAL_STATES[Number(stateNum)] || `Unknown (${stateNum})`;
130
+ }
131
+
132
+ async function handleKnownError(error, { govAddress, proposalStake, sxxxAddress, rpcUrl, governor, targets, values, calldatas, description, subdaoAddress }) {
127
133
  const message = (error?.message || String(error)).toLowerCase();
134
+ const errorData = error?.data || error?.error?.data || '';
135
+
136
+ // Detect GovernorUnexpectedProposalState (0x31b75e4d) - duplicate proposal
137
+ if (message.includes('0x31b75e4d') || message.includes('governorunexpectedproposalstate') || errorData.includes('0x31b75e4d')) {
138
+ logger.log?.('');
139
+ logger.log?.('❌ A proposal with the same content already exists.');
140
+ logger.log?.('');
141
+
142
+ // Try to compute and look up the existing proposal
143
+ if (governor && targets && calldatas && description) {
144
+ try {
145
+ const descHash = ethersLib.keccak256(ethersLib.toUtf8Bytes(description));
146
+ const proposalId = await governor.hashProposal(targets, values || [0n], calldatas, descHash);
147
+ const state = await governor.state(proposalId);
148
+ const stateName = getProposalStateName(state);
149
+
150
+ logger.log?.('📋 Existing proposal:');
151
+ logger.log?.(` ID: ${proposalId}`);
152
+ logger.log?.(` State: ${stateName}`);
153
+
154
+ // Get deadline if available
155
+ try {
156
+ const deadline = await governor.proposalDeadline(proposalId);
157
+ logger.log?.(` Deadline: block ${deadline}`);
158
+ } catch (_) { /* deadline not available */ }
159
+
160
+ logger.log?.('');
161
+ logger.log?.('💡 Next steps:');
162
+
163
+ const subdaoFlag = subdaoAddress ? ` --subdao ${subdaoAddress}` : '';
164
+ if (state === 0n || state === 1n) {
165
+ // Pending or Active - can vote
166
+ logger.log?.(` • Vote FOR: sage gov vote ${proposalId} 1${subdaoFlag}`);
167
+ logger.log?.(` • Check status: sage gov status ${proposalId}${subdaoFlag}`);
168
+ } else if (state === 5n) {
169
+ // Queued - ready to execute
170
+ logger.log?.(` • Execute: sage gov execute ${proposalId}${subdaoFlag}`);
171
+ } else if (state === 7n) {
172
+ // Already executed
173
+ logger.log?.(' • This proposal was already executed successfully.');
174
+ logger.log?.(' • If you need a new update, modify the description to create a unique proposal.');
175
+ } else {
176
+ logger.log?.(` • Check status: sage gov status ${proposalId}${subdaoFlag}`);
177
+ }
178
+ } catch (lookupErr) {
179
+ logger.log?.(' Could not look up existing proposal details.');
180
+ logger.log?.(' Use `sage proposals inbox` to find active proposals.');
181
+ }
182
+ } else {
183
+ logger.log?.('💡 Use `sage proposals inbox` to find and vote on existing proposals.');
184
+ }
185
+ logger.log?.('');
186
+ return { aborted: true, reason: 'duplicate-proposal' };
187
+ }
188
+
128
189
  if (message.includes('allowance')) {
129
190
  logger.log?.('❌ Unexpected allowance error during proposal submission');
130
191
  logger.log?.('Please ensure you have approved SXXX for the Governor:');
@@ -157,7 +218,7 @@ function createProposalSubmitter(overrides = {}) {
157
218
  try {
158
219
  return await submitProposal(options);
159
220
  } catch (error) {
160
- const known = handleKnownError(error, options);
221
+ const known = await handleKnownError(error, options);
161
222
  if (known) return known;
162
223
  throw error;
163
224
  }
@@ -708,6 +708,119 @@ class SXXXManager {
708
708
  }
709
709
  }
710
710
 
711
+ /**
712
+ * Delegate voting power to a specific address.
713
+ * Use this to delegate to a representative, or delegate-self to take back your voting power.
714
+ * @param {string} delegatee - The address to delegate voting power to
715
+ */
716
+ async delegateTo(delegatee) {
717
+ try {
718
+ // Validate delegatee address
719
+ if (!delegatee || !ethers.isAddress(delegatee)) {
720
+ throw new Error(`Invalid delegatee address: ${delegatee}`);
721
+ }
722
+
723
+ console.log(colors.blue('🗳️ Delegating votes to address...'));
724
+ console.log('Delegatee:', delegatee);
725
+
726
+ // Get user address - handle both Cast and regular signers
727
+ let userAddress;
728
+ if (!this.signer) {
729
+ const WalletManager = require('./wallet-manager');
730
+ const walletManager = new WalletManager();
731
+ await walletManager.connect();
732
+ userAddress = walletManager.getAccount();
733
+ } else {
734
+ userAddress = await this.signer.getAddress();
735
+ }
736
+
737
+ console.log('Your address:', userAddress);
738
+
739
+ // Check if delegating to self
740
+ const isSelfDelegation = delegatee.toLowerCase() === userAddress.toLowerCase();
741
+ if (isSelfDelegation) {
742
+ console.log('ℹ️ Delegating to yourself (same as delegate-self)');
743
+ }
744
+
745
+ // Use base votes token
746
+ const vt = this.votesToken;
747
+ if (!vt) {
748
+ console.log('⚠️ No base voting token resolved for this context.');
749
+ throw new Error('No base voting token resolved for delegation.');
750
+ }
751
+
752
+ // Check current delegation
753
+ try {
754
+ const currentDelegate = await vt.delegates(userAddress);
755
+ console.log('Currently delegates to:', currentDelegate);
756
+
757
+ if (currentDelegate.toLowerCase() === delegatee.toLowerCase()) {
758
+ console.log('✅ Already delegated to this address!');
759
+ return;
760
+ }
761
+
762
+ // Warn if revoking self-delegation
763
+ if (currentDelegate.toLowerCase() === userAddress.toLowerCase() && !isSelfDelegation) {
764
+ console.log(colors.yellow('⚠️ You are transferring your voting power to someone else.'));
765
+ console.log(colors.yellow(' To take back your voting power later: sage sxxx delegate-self'));
766
+ }
767
+ } catch (e) {
768
+ console.log('Could not check current delegation');
769
+ }
770
+
771
+ // Build transaction
772
+ const to = vt.target;
773
+ const data = vt.interface.encodeFunctionData('delegate', [delegatee]);
774
+
775
+ // Estimate gas
776
+ let gasLimit;
777
+ try {
778
+ const est = await (this.provider || vt.runner?.provider).estimateGas({ from: userAddress, to, data, value: 0 }).catch(() => null);
779
+ if (est) gasLimit = est + (est / 5n);
780
+ } catch (_) { /* non-fatal */ }
781
+
782
+ const withTimeout = (p, ms, label = 'operation') => {
783
+ const t = Number(ms || 15000);
784
+ return Promise.race([
785
+ p,
786
+ new Promise((_, rej) => setTimeout(() => rej(new Error(`${label} timed out after ${t}ms`)), t))
787
+ ]);
788
+ };
789
+
790
+ const actionLabel = isSelfDelegation ? 'self-delegation' : `delegation to ${delegatee.slice(0, 10)}...`;
791
+
792
+ if (!this.signer) {
793
+ console.log(colors.yellow('🔐 Using Cast wallet for delegation transaction...'));
794
+ const CastWalletManager = require('./cast-wallet-manager');
795
+ const castWallet = new CastWalletManager();
796
+ await castWallet.connect();
797
+ const transaction = { to, data, gasLimit: gasLimit || 120000 };
798
+ const result = await withSpinner(`Submitting ${actionLabel}`, async () => {
799
+ return withTimeout(castWallet.signTransaction(transaction), process.env.SAGE_SEND_TIMEOUT_MS, 'delegate(send)');
800
+ }, { successText: ({ hash }) => `Delegation submitted - tx ${hash}` });
801
+ return result;
802
+ } else {
803
+ const sent = await withSpinner(`Submitting ${actionLabel}`, async () => {
804
+ const send = this.signer.sendTransaction({ to, data, gasLimit, value: 0 });
805
+ const tx = await withTimeout(send, process.env.SAGE_SEND_TIMEOUT_MS, 'delegate(send)');
806
+ const rc = await waitForReceipt(this.provider, tx, Number(process.env.SAGE_TX_WAIT_MS || 60000));
807
+ return { tx, receipt: rc };
808
+ }, { successText: ({ tx }) => `Delegation confirmed - tx ${tx.hash}` });
809
+
810
+ if (isSelfDelegation) {
811
+ console.log('✅ Voting power returned to you.');
812
+ } else {
813
+ console.log(`✅ Voting power delegated to ${delegatee}`);
814
+ console.log('💡 To take back your voting power: sage sxxx delegate-self');
815
+ }
816
+ return sent.receipt;
817
+ }
818
+ } catch (error) {
819
+ console.error('❌ Failed to delegate votes:', error.message);
820
+ throw error;
821
+ }
822
+ }
823
+
711
824
  async checkDelegation(address) {
712
825
  try {
713
826
  // Get target address - handle both Cast and regular signers
@@ -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
+ };
@@ -39,7 +39,7 @@ async function deriveDestination({ provider, subdaoOpt, workspaceSubdao, aliasRe
39
39
  } else {
40
40
  const mode = await detectGovMode({ provider, subdao: out.subdao, governor: out.governor, timelock: out.timelock });
41
41
  // Prefer the explicit 3-axis profile when available
42
- if (mode?.governanceKind === 'TOKEN') {
42
+ if (mode?.governanceKind === 'TOKEN' || mode?.route === 'governor-proposal') {
43
43
  out.route = 'community';
44
44
  out.reason = mode.route || 'token-governance';
45
45
  } else if (mode?.playbook === 'council-closed' || mode?.route === 'safe-multisig') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sage-protocol/cli",
3
- "version": "0.6.5",
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"