@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.
- package/dist/cli/commands/governance.js +61 -1
- package/dist/cli/commands/sxxx.js +45 -1
- package/dist/cli/privy-auth-wallet-manager.js +54 -25
- package/dist/cli/services/governance/mode-detector.js +16 -7
- package/dist/cli/services/library/operator-mode.js +1 -1
- package/dist/cli/services/library/proposal-diagnostics.js +52 -0
- package/dist/cli/services/library/proposal-submit.js +63 -2
- package/dist/cli/sxxx-manager.js +113 -0
- package/dist/cli/utils/gov-timing.js +190 -0
- package/dist/cli/utils/publish-dest.js +1 -1
- package/package.json +1 -1
|
@@ -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')) {
|
|
@@ -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
|
-
|
|
502
|
-
/*
|
|
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
|
|
542
|
+
padding: 12px 16px !important;
|
|
508
543
|
box-sizing: border-box !important;
|
|
544
|
+
font-size: 16px !important;
|
|
545
|
+
width: 100% !important;
|
|
509
546
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
[
|
|
513
|
-
[class*="
|
|
514
|
-
div[role="dialog"] button
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
555
|
+
|
|
556
|
+
/* Ensure forms have proper layout */
|
|
530
557
|
[id*="privy"] form,
|
|
531
558
|
[class*="privy"] form,
|
|
532
559
|
div[role="dialog"] form {
|
|
533
|
-
|
|
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@
|
|
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
|
-
|
|
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.
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/cli/sxxx-manager.js
CHANGED
|
@@ -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') {
|