@sage-protocol/cli 0.8.0 → 0.8.3

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.

Potentially problematic release.


This version of @sage-protocol/cli might be problematic. Click here for more details.

Files changed (34) hide show
  1. package/README.md +12 -11
  2. package/dist/cli/commands/boost.js +339 -62
  3. package/dist/cli/commands/bounty.js +28 -4
  4. package/dist/cli/commands/config.js +10 -1
  5. package/dist/cli/commands/contributor.js +16 -6
  6. package/dist/cli/commands/dao.js +1 -1
  7. package/dist/cli/commands/discover.js +3 -3
  8. package/dist/cli/commands/governance.js +141 -58
  9. package/dist/cli/commands/install.js +178 -36
  10. package/dist/cli/commands/ipfs.js +12 -2
  11. package/dist/cli/commands/library.js +277 -268
  12. package/dist/cli/commands/members.js +132 -18
  13. package/dist/cli/commands/multiplier.js +101 -13
  14. package/dist/cli/commands/nft.js +16 -3
  15. package/dist/cli/commands/personal.js +69 -2
  16. package/dist/cli/commands/prompt.js +1 -1
  17. package/dist/cli/commands/proposals.js +153 -3
  18. package/dist/cli/commands/stake-status.js +130 -56
  19. package/dist/cli/commands/sxxx.js +37 -4
  20. package/dist/cli/commands/wallet.js +5 -10
  21. package/dist/cli/contracts/index.js +2 -1
  22. package/dist/cli/index.js +5 -0
  23. package/dist/cli/privy-auth-wallet-manager.js +3 -2
  24. package/dist/cli/services/config/chain-defaults.js +1 -1
  25. package/dist/cli/services/config/manager.js +3 -0
  26. package/dist/cli/services/config/schema.js +1 -0
  27. package/dist/cli/services/ipfs/onboarding.js +11 -0
  28. package/dist/cli/utils/aliases.js +62 -3
  29. package/dist/cli/utils/cli-ui.js +1 -1
  30. package/dist/cli/utils/provider.js +7 -3
  31. package/dist/cli/wallet-manager.js +7 -12
  32. package/dist/prompts/e2e-test-prompt.md +22 -0
  33. package/dist/prompts/skills/build-web3/plugin.json +11 -0
  34. package/package.json +1 -1
@@ -7,6 +7,57 @@ const { handleCLIError } = require('../utils/error-handler');
7
7
  const subgraphClient = require('../utils/subgraph-client');
8
8
  const cliConfig = require('../config');
9
9
 
10
+ async function getLogsChunked(provider, filter, maxRange = 50000) {
11
+ // Default to 50k blocks to stay under most RPC limits (Base Sepolia limits to 100k)
12
+ const latest = filter.toBlock === 'latest' || typeof filter.toBlock === 'undefined'
13
+ ? await provider.getBlockNumber()
14
+ : Number(filter.toBlock);
15
+ const from = Number(filter.fromBlock || 0);
16
+ const results = [];
17
+ for (let start = from; start <= latest; start += maxRange) {
18
+ const end = Math.min(latest, start + maxRange - 1);
19
+ try {
20
+ const batch = await provider.getLogs({ ...filter, fromBlock: start, toBlock: end });
21
+ if (batch && batch.length) results.push(...batch);
22
+ } catch (e) {
23
+ if (process.env.SAGE_VERBOSE === '1') {
24
+ console.warn(`Log query failed for blocks ${start}-${end}: ${e.message}`);
25
+ }
26
+ }
27
+ }
28
+ return results;
29
+ }
30
+
31
+ async function findProposalCreatedLog(provider, govAddr, gov, pid, lookbacks) {
32
+ const { ethers } = require('ethers');
33
+ const eventSig = gov.interface.getEvent('ProposalCreated').topicHash;
34
+ const currentBlock = await provider.getBlockNumber();
35
+ const pidHex = ethers.hexlify(ethers.toBeHex(pid));
36
+ for (const span of lookbacks) {
37
+ const from = currentBlock > span ? currentBlock - span : 0;
38
+ let logs = await getLogsChunked(provider, {
39
+ topics: [eventSig, ethers.zeroPadValue(pidHex, 32)],
40
+ address: govAddr,
41
+ fromBlock: from,
42
+ toBlock: 'latest'
43
+ });
44
+ if (!logs.length) {
45
+ const all = await getLogsChunked(provider, { topics: [eventSig], address: govAddr, fromBlock: from, toBlock: 'latest' });
46
+ for (const l of all) {
47
+ try {
48
+ const p = gov.interface.parseLog(l);
49
+ if (p && p.name === 'ProposalCreated' && BigInt(p.args.proposalId.toString()) === pid) {
50
+ logs = [l];
51
+ break;
52
+ }
53
+ } catch { }
54
+ }
55
+ }
56
+ if (logs.length) return logs[0];
57
+ }
58
+ return null;
59
+ }
60
+
10
61
  function register(program) {
11
62
  const cmd = new Command('proposals')
12
63
  .description('Proposals navigation: inbox, preview, vote, execute');
@@ -219,10 +270,92 @@ function register(program) {
219
270
  if (summary.description) ui.output(` Description: ${summary.description.slice(0, 100)}...`);
220
271
  }
221
272
  } catch (e) {
222
- // Final fallback: minimal info
223
273
  if (process.env.SAGE_VERBOSE === '1') {
224
274
  ui.warn(`SDK getProposalDetails failed: ${e.message}`);
225
275
  }
276
+ // Fallback 1: try subgraph directly (no block range limits)
277
+ let foundViaSubgraph = false;
278
+ try {
279
+ const status = await subgraphClient.checkSubgraphStatus(provider);
280
+ if (status.available && ctx.governor) {
281
+ const proposals = await subgraphClient.getProposals(ctx.governor, { first: 200 });
282
+ const pidDec = pid.toString();
283
+ const match = proposals.find((p) => {
284
+ const parts = String(p.id || '').split('-');
285
+ const last = parts.length > 1 ? parts[parts.length - 1] : String(p.id || '');
286
+ if (!last) return false;
287
+ if (last === pidDec) return true;
288
+ try { if (String(last).startsWith('0x') && BigInt(String(last)) === pid) return true; } catch (_) { }
289
+ return false;
290
+ });
291
+ if (match && match.targets?.length) {
292
+ const targets = Array.from(match.targets || [], (a) => String(a));
293
+ const values = Array.from(match.values || [], (v) => BigInt(String(v || 0)));
294
+ const calldatas = Array.from(match.calldatas || [], (b) => String(b));
295
+ const description = String(match.description || '');
296
+ const selectors = calldatas.map((d) => (typeof d === 'string' ? d.slice(0, 10) : '0x'));
297
+ const summary = {
298
+ id: pid.toString(),
299
+ source: 'subgraph',
300
+ actionCount: targets.length,
301
+ targets,
302
+ values: values.map((v) => v.toString()),
303
+ selectors,
304
+ description
305
+ };
306
+ if (opts.json) ui.json(withJsonVersion(summary));
307
+ else {
308
+ ui.output(`Proposal ${summary.id} (subgraph)`);
309
+ ui.output(` Actions: ${summary.actionCount}`);
310
+ if (summary.targets.length) ui.output(` First target: ${summary.targets[0]}`);
311
+ if (summary.selectors.length) ui.output(` First selector: ${summary.selectors[0]}`);
312
+ if (summary.description) ui.output(` Description: ${summary.description.slice(0, 100)}...`);
313
+ }
314
+ foundViaSubgraph = true;
315
+ }
316
+ }
317
+ } catch (sgErr) {
318
+ if (process.env.SAGE_VERBOSE === '1') {
319
+ ui.warn(`Subgraph fallback failed: ${sgErr.message}`);
320
+ }
321
+ }
322
+ if (foundViaSubgraph) return;
323
+
324
+ // Fallback 2: scan ProposalCreated logs with chunked queries
325
+ try {
326
+ if (ctx.governor) {
327
+ const GovABI = require('../utils/artifacts').resolveArtifact('contracts/cloneable/PromptGovernorCloneable.sol/PromptGovernorCloneable.json').abi;
328
+ const gov = new ethers.Contract(ctx.governor, GovABI, provider);
329
+ const hit = await findProposalCreatedLog(provider, ctx.governor, gov, pid, [20000, 100000, 500000]);
330
+ if (hit) {
331
+ const parsed = gov.interface.parseLog(hit);
332
+ const targets = Array.from(parsed.args.targets || [], (a) => String(a));
333
+ const values = Array.from(parsed.args.values || [], (v) => BigInt(v.toString()));
334
+ const calldatas = Array.from(parsed.args.calldatas || [], (b) => ethers.hexlify(b));
335
+ const description = String(parsed.args.description ?? '');
336
+ const selectors = calldatas.map((d) => (typeof d === 'string' ? d.slice(0, 10) : '0x'));
337
+ const summary = {
338
+ id: pid.toString(),
339
+ source: 'logs',
340
+ actionCount: targets.length,
341
+ targets,
342
+ values: values.map((v) => v.toString()),
343
+ selectors,
344
+ description
345
+ };
346
+ if (opts.json) ui.json(withJsonVersion(summary));
347
+ else {
348
+ ui.output(`Proposal ${summary.id} (logs)`);
349
+ ui.output(` Actions: ${summary.actionCount}`);
350
+ if (summary.targets.length) ui.output(` First target: ${summary.targets[0]}`);
351
+ if (summary.selectors.length) ui.output(` First selector: ${summary.selectors[0]}`);
352
+ if (summary.description) ui.output(` Description: ${summary.description.slice(0, 100)}...`);
353
+ }
354
+ return;
355
+ }
356
+ }
357
+ } catch (_) { }
358
+ // Final fallback: minimal info
226
359
  const summary = { id: pid.toString(), description: '(unable to fetch proposal details)' };
227
360
  if (opts.json) ui.json(withJsonVersion(summary));
228
361
  else ui.output(`Proposal ${summary.id} - ${summary.description}`);
@@ -795,10 +928,27 @@ function register(program) {
795
928
  return;
796
929
  }
797
930
 
798
- // Delegate to existing library-manager execute flow if it is a library update; otherwise, try queue/execute via gm
931
+ // Queue only when needed; skip queue if already queued
932
+ let stateNum = null;
933
+ let stateName = 'unknown';
799
934
  try {
800
- await gm.queueProposal(id);
935
+ const state = await gm.governor.state(gm.normalizeProposalId(id));
936
+ stateNum = Number(state);
937
+ stateName = gm.getStateName ? gm.getStateName(stateNum) : String(stateNum);
801
938
  } catch (_) {}
939
+
940
+ if (stateNum === 4) {
941
+ try {
942
+ await gm.queueProposal(id);
943
+ } catch (_) {}
944
+ } else if (stateNum === 5) {
945
+ if (!process.env.SAGE_QUIET_JSON) {
946
+ console.log(`ℹ️ Proposal already queued (state ${stateName}). Skipping queue step.`);
947
+ }
948
+ } else if (stateNum !== null && !process.env.SAGE_QUIET_JSON) {
949
+ console.log(`⚠️ Proposal is in state ${stateName}; queue may fail.`);
950
+ }
951
+
802
952
  await gm.executeProposal(id);
803
953
  ui.success('Execute complete');
804
954
  } catch (e) {
@@ -11,19 +11,21 @@ function register(program) {
11
11
  .option('--governor <address>', 'Governor address to check')
12
12
  .option('--address <address>', 'Address to check (defaults to connected wallet)')
13
13
  .option('-v, --verbose', 'Show detailed output')
14
+ .option('--json', 'Output JSON', false)
14
15
  .action(async (opts) => {
15
16
  try {
16
- ui.configure({ verbose: opts.verbose });
17
+ ui.configure({ verbose: opts.verbose, json: opts.json });
18
+ const show = !opts.json;
17
19
 
18
20
  const WalletManager = require('../wallet-manager');
19
21
  const { resolveGovContext } = require('../utils/gov-context');
20
22
 
21
23
  const invokedAsStakeStatus = process.argv.includes('stake-status');
22
- if (invokedAsStakeStatus) {
24
+ if (invokedAsStakeStatus && show) {
23
25
  ui.warn('`sage stake-status` is deprecated. Use `sage voting-status` (governance uses delegation, not staking).');
24
26
  }
25
27
 
26
- const rpcUrl = process.env.RPC_URL || process.env.BASE_SEPOLIA_RPC || 'https://base-sepolia.publicnode.com';
28
+ const rpcUrl = process.env.SAGE_RPC_URL || process.env.RPC_URL || process.env.BASE_SEPOLIA_RPC || 'https://base-sepolia.publicnode.com';
27
29
  const provider = new ethers.JsonRpcProvider(rpcUrl);
28
30
 
29
31
  // Get wallet manager and connect
@@ -32,11 +34,13 @@ function register(program) {
32
34
  const signer = walletManager.getSigner();
33
35
  const address = opts.address || await signer.getAddress();
34
36
 
35
- ui.header('Voting Power Status');
36
- ui.keyValue([
37
- ['Address', address],
38
- ['RPC', rpcUrl]
39
- ]);
37
+ if (show) {
38
+ ui.header('Voting Power Status');
39
+ ui.keyValue([
40
+ ['Address', address],
41
+ ['RPC', rpcUrl]
42
+ ]);
43
+ }
40
44
 
41
45
  // Resolve governance context
42
46
  const ctx = await resolveGovContext({
@@ -48,14 +52,17 @@ function register(program) {
48
52
  });
49
53
 
50
54
  if (!ctx.governor) {
51
- ui.error('No Governor found');
55
+ if (show) ui.error('No Governor found');
56
+ else ui.json({ ok: false, error: 'No Governor found' });
52
57
  return;
53
58
  }
54
59
 
55
- ui.keyValue([
56
- ['Governor', ctx.governor],
57
- ['SubDAO', ctx.subdao || 'N/A']
58
- ]);
60
+ if (show) {
61
+ ui.keyValue([
62
+ ['Governor', ctx.governor],
63
+ ['SubDAO', ctx.subdao || 'N/A']
64
+ ]);
65
+ }
59
66
 
60
67
  // Get Governor's voting token and multiplier status
61
68
  const govABI = [
@@ -78,19 +85,21 @@ function register(program) {
78
85
  const baseVotesTokenAddr = await governor.baseVotesToken().catch(() => votingToken);
79
86
  const isWrapperMode = isMultiplierEnabled || (baseVotesTokenAddr && baseVotesTokenAddr !== votingToken);
80
87
 
81
- if (isWrapperMode) {
82
- ui.keyValue([
83
- ['Voting Token (wrapper)', votingToken],
84
- ['Base Token (SXXX)', baseVotesTokenAddr],
85
- ['NFT Multipliers', isMultiplierEnabled ? 'Enabled' : 'Unknown']
86
- ]);
87
- } else {
88
- ui.keyValue([
89
- ['Voting Token', votingToken],
90
- ['NFT Multipliers', 'Disabled']
91
- ]);
88
+ if (show) {
89
+ if (isWrapperMode) {
90
+ ui.keyValue([
91
+ ['Voting Token (wrapper)', votingToken],
92
+ ['Base Token (SXXX)', baseVotesTokenAddr],
93
+ ['NFT Multipliers', isMultiplierEnabled ? 'Enabled' : 'Unknown']
94
+ ]);
95
+ } else {
96
+ ui.keyValue([
97
+ ['Voting Token', votingToken],
98
+ ['NFT Multipliers', 'Disabled']
99
+ ]);
100
+ }
101
+ ui.info(`Proposal Threshold: ${ethers.formatEther(threshold)} tokens`);
92
102
  }
93
- ui.info(`Proposal Threshold: ${ethers.formatEther(threshold)} tokens`);
94
103
 
95
104
  // In multiplier mode, governor.token() is an IVotes wrapper (MultipliedVotes) which is NOT an ERC20,
96
105
  // so ERC20 calls (name/symbol/decimals/balanceOf) can revert. Always read ERC20 metadata from the base token.
@@ -117,35 +126,39 @@ function register(program) {
117
126
  provider.getBlockNumber()
118
127
  ]);
119
128
 
120
- ui.newline();
121
- ui.header('Token Details');
122
- ui.keyValue([
123
- ['Name', name],
124
- ['Symbol', symbol],
125
- ['Decimals', decimals],
126
- ['Balance', `${ethers.formatUnits(balance, decimals)} ${symbol}`],
127
- ['Delegates to', delegates]
128
- ]);
129
+ if (show) {
130
+ ui.newline();
131
+ ui.header('Token Details');
132
+ ui.keyValue([
133
+ ['Name', name],
134
+ ['Symbol', symbol],
135
+ ['Decimals', decimals],
136
+ ['Balance', `${ethers.formatUnits(balance, decimals)} ${symbol}`],
137
+ ['Delegates to', delegates]
138
+ ]);
139
+ }
129
140
 
130
141
  // Check voting power at different blocks
131
142
  const blocksToCheck = [currentBlock - 1, currentBlock - 2, currentBlock - 5];
132
- ui.newline();
133
- ui.header('Voting Power History');
143
+ if (show) {
144
+ ui.newline();
145
+ ui.header('Voting Power History');
146
+ }
134
147
 
135
148
  for (const block of blocksToCheck) {
136
149
  try {
137
150
  const votes = await votesToken.getPastVotes(address, block);
138
151
  const formattedVotes = ethers.formatUnits(votes, decimals);
139
152
  const status = votes >= threshold ? ui.symbols.check : ui.symbols.cross;
140
- ui.output(` Block ${block}: ${formattedVotes} ${symbol} ${status}`);
153
+ if (show) ui.output(` Block ${block}: ${formattedVotes} ${symbol} ${status}`);
141
154
  } catch (e) {
142
- ui.output(` Block ${block}: Error - ${e.message}`);
155
+ if (show) ui.output(` Block ${block}: Error - ${e.message}`);
143
156
  }
144
157
  }
145
158
 
146
159
  // Note: stakeToken() removed from SubDAO contracts (threshold-based governance)
147
160
  // Voting power now comes from SXXX token delegation, not staking
148
- if (ctx.subdao) {
161
+ if (ctx.subdao && show) {
149
162
  ui.newline();
150
163
  ui.header('Governance Model');
151
164
  ui.output(' This SubDAO uses threshold-based governance.');
@@ -154,8 +167,10 @@ function register(program) {
154
167
 
155
168
  // Summary
156
169
  const currentVotes = await votesToken.getPastVotes(address, currentBlock - 1).catch(() => 0n);
157
- ui.newline();
158
- ui.header('Summary');
170
+ if (show) {
171
+ ui.newline();
172
+ ui.header('Summary');
173
+ }
159
174
 
160
175
  // If multipliers are enabled, show both base and effective voting power
161
176
  if (isWrapperMode) {
@@ -163,32 +178,91 @@ function register(program) {
163
178
  const [baseVotes, multiplier, effectiveVotes] = await governor.getVotingBreakdown(address);
164
179
  const multiplierDisplay = (Number(multiplier) / 10000).toFixed(2);
165
180
  const canPropose = effectiveVotes >= threshold;
166
- ui.keyValue([
167
- ['Base Voting Power', `${ethers.formatUnits(baseVotes, decimals)} ${symbol}`],
168
- ['NFT Multiplier', `${multiplierDisplay}x`],
169
- ['Effective Voting Power', `${ethers.formatUnits(effectiveVotes, decimals)} votes`],
170
- ['Proposal Threshold', `${ethers.formatEther(threshold)} tokens`],
171
- ['Can Propose', canPropose ? 'YES' : 'NO']
172
- ]);
181
+ if (show) {
182
+ ui.keyValue([
183
+ ['Base Voting Power', `${ethers.formatUnits(baseVotes, decimals)} ${symbol}`],
184
+ ['NFT Multiplier', `${multiplierDisplay}x`],
185
+ ['Effective Voting Power', `${ethers.formatUnits(effectiveVotes, decimals)} votes`],
186
+ ['Proposal Threshold', `${ethers.formatEther(threshold)} tokens`],
187
+ ['Can Propose', canPropose ? 'YES' : 'NO']
188
+ ]);
189
+ } else {
190
+ ui.json({
191
+ ok: true,
192
+ address,
193
+ rpcUrl,
194
+ governor: ctx.governor,
195
+ subdao: ctx.subdao || null,
196
+ votingToken,
197
+ baseToken: baseVotesTokenAddr,
198
+ isMultiplierEnabled: !!isMultiplierEnabled,
199
+ threshold: threshold.toString(),
200
+ balance: balance.toString(),
201
+ delegates,
202
+ baseVotes: baseVotes.toString(),
203
+ multiplier: multiplier.toString(),
204
+ effectiveVotes: effectiveVotes.toString(),
205
+ canPropose
206
+ });
207
+ return;
208
+ }
173
209
  } catch (e) {
174
210
  // Fallback if getVotingBreakdown fails
175
211
  const canPropose = currentVotes >= threshold;
212
+ if (show) {
213
+ ui.keyValue([
214
+ ['Current Voting Power', `${ethers.formatUnits(currentVotes, decimals)} ${symbol}`],
215
+ ['Proposal Threshold', `${ethers.formatEther(threshold)} tokens`],
216
+ ['Can Propose', canPropose ? 'YES' : 'NO']
217
+ ]);
218
+ } else {
219
+ ui.json({
220
+ ok: true,
221
+ address,
222
+ rpcUrl,
223
+ governor: ctx.governor,
224
+ subdao: ctx.subdao || null,
225
+ votingToken,
226
+ baseToken: baseVotesTokenAddr,
227
+ isMultiplierEnabled: !!isMultiplierEnabled,
228
+ threshold: threshold.toString(),
229
+ balance: balance.toString(),
230
+ delegates,
231
+ currentVotes: currentVotes.toString(),
232
+ canPropose
233
+ });
234
+ return;
235
+ }
236
+ }
237
+ } else {
238
+ const canPropose = currentVotes >= threshold;
239
+ if (show) {
176
240
  ui.keyValue([
177
241
  ['Current Voting Power', `${ethers.formatUnits(currentVotes, decimals)} ${symbol}`],
178
242
  ['Proposal Threshold', `${ethers.formatEther(threshold)} tokens`],
179
243
  ['Can Propose', canPropose ? 'YES' : 'NO']
180
244
  ]);
245
+ } else {
246
+ ui.json({
247
+ ok: true,
248
+ address,
249
+ rpcUrl,
250
+ governor: ctx.governor,
251
+ subdao: ctx.subdao || null,
252
+ votingToken,
253
+ baseToken: baseVotesTokenAddr,
254
+ isMultiplierEnabled: !!isMultiplierEnabled,
255
+ threshold: threshold.toString(),
256
+ balance: balance.toString(),
257
+ delegates,
258
+ currentVotes: currentVotes.toString(),
259
+ canPropose
260
+ });
261
+ return;
181
262
  }
182
- } else {
183
- const canPropose = currentVotes >= threshold;
184
- ui.keyValue([
185
- ['Current Voting Power', `${ethers.formatUnits(currentVotes, decimals)} ${symbol}`],
186
- ['Proposal Threshold', `${ethers.formatEther(threshold)} tokens`],
187
- ['Can Propose', canPropose ? 'YES' : 'NO']
188
- ]);
189
263
  }
190
264
 
191
- if (currentVotes < threshold) {
265
+ if (show && currentVotes < threshold) {
192
266
  ui.newline();
193
267
  ui.info('To gain voting power:');
194
268
  if (balance === 0n) {
@@ -22,10 +22,14 @@ function register(program) {
22
22
  try {
23
23
  ui.configure({ verbose: opts.verbose });
24
24
  const { ethers } = require('ethers');
25
- const rpcUrl = cliConfig.resolveRpcUrl();
25
+ const rpcUrl = cliConfig.resolveRpcUrl({ allowEnv: true });
26
26
  const provider = new ethers.JsonRpcProvider(rpcUrl);
27
27
  const tokenAddress = cliConfig.resolveAddress('SXXX_TOKEN_ADDRESS');
28
28
  if (!tokenAddress) throw new Error('SXXX_TOKEN_ADDRESS not set');
29
+ const code = await provider.getCode(tokenAddress);
30
+ if (!code || code === '0x' || code === '0x0') {
31
+ throw new Error(`SXXX contract not found at ${tokenAddress}. Check SAGE_RPC_URL/CHAIN_ID or import addresses.`);
32
+ }
29
33
  let targetAddress = address;
30
34
  // 1) Prefer configured default account from profile
31
35
  if (!targetAddress) {
@@ -46,9 +50,38 @@ function register(program) {
46
50
  } catch (_) {}
47
51
  }
48
52
  if (!targetAddress) throw new Error('No address provided and could not resolve wallet account');
49
- const abi = ['function balanceOf(address) view returns (uint256)'];
53
+ const abi = ['function balanceOf(address) view returns (uint256)', 'function symbol() view returns (string)'];
50
54
  const token = new ethers.Contract(tokenAddress, abi, provider);
51
- const bal = await token.balanceOf(targetAddress);
55
+
56
+ // Verify contract exists and is an ERC20
57
+ try {
58
+ const code = await provider.getCode(tokenAddress);
59
+ if (code === '0x') {
60
+ ui.error(`SXXX contract not deployed at ${tokenAddress}`);
61
+ ui.info(' This may mean the address is incorrect or the network is wrong.');
62
+ ui.info(' Check: RPC_URL, SXXX_TOKEN_ADDRESS, or run `sage doctor` for diagnostics.');
63
+ process.exit(1);
64
+ }
65
+ } catch (codeErr) {
66
+ // Ignore code check failure, proceed with balance check
67
+ }
68
+
69
+ let bal;
70
+ try {
71
+ bal = await token.balanceOf(targetAddress);
72
+ } catch (balErr) {
73
+ const errMsg = balErr.message || String(balErr);
74
+ if (errMsg.includes('BAD_DATA') || errMsg.includes('could not decode result')) {
75
+ ui.error(`Contract at ${tokenAddress} returned invalid data for balanceOf()`);
76
+ ui.info(' This usually means the address is not an ERC20 token contract.');
77
+ ui.info(' Possible fixes:');
78
+ ui.info(' - Check SXXX_TOKEN_ADDRESS is correct for your network');
79
+ ui.info(' - Verify RPC_URL points to the correct chain (Base Sepolia)');
80
+ ui.info(' - Run `sage doctor` for full diagnostics');
81
+ process.exit(1);
82
+ }
83
+ throw balErr;
84
+ }
52
85
  ui.success(`SXXX Balance for ${ui.formatAddress(targetAddress)}: ${ui.formatToken(bal, { symbol: 'SXXX' })}`);
53
86
  } catch (error) {
54
87
  handleCLIError('sxxx:balance', error, { exit: true });
@@ -62,7 +95,7 @@ function register(program) {
62
95
  .action(async (opts) => {
63
96
  try {
64
97
  ui.configure({ verbose: opts.verbose });
65
- const rpcUrl = cliConfig.resolveRpcUrl();
98
+ const rpcUrl = cliConfig.resolveRpcUrl({ allowEnv: true });
66
99
  const tokenAddress = cliConfig.resolveAddress('SXXX_TOKEN_ADDRESS');
67
100
  if (!tokenAddress) throw new Error('SXXX_TOKEN_ADDRESS not set (configure via .env or .sage profile)');
68
101
  const provider = new ethers.JsonRpcProvider(rpcUrl);
@@ -289,9 +289,10 @@ const Web3AuthWalletManager = require('../web3auth-wallet-manager');
289
289
  }
290
290
 
291
291
  // Device code flow via relay works with just appId (no secret needed)
292
- // Only --local mode requires appSecret for direct Privy SDK calls
292
+ // Always prefer relay unless --local is explicitly set
293
293
  if (appId) {
294
294
  if (options?.local) {
295
+ process.env.SAGE_PRIVY_LOGIN_MODE = 'local';
295
296
  // Local OAuth requires appSecret for direct Privy SDK authentication
296
297
  if (!appSecret) {
297
298
  ui.error('Local OAuth mode requires PRIVY_APP_SECRET.');
@@ -300,15 +301,9 @@ const Web3AuthWalletManager = require('../web3auth-wallet-manager');
300
301
  }
301
302
  ui.info('Using local OAuth authentication...');
302
303
  } else {
303
- // Web/device code flow - uses relay, no secret needed
304
- // Force device code mode when secret is not available
305
- if (!appSecret) {
306
- process.env.SAGE_PRIVY_LOGIN_MODE = 'device';
307
- ui.info('Using device code authentication via web app relay...');
308
- } else {
309
- process.env.SAGE_PRIVY_LOGIN_MODE = 'web';
310
- ui.info('Using web-based authentication...');
311
- }
304
+ // Web relay flow - uses web app, no localhost callback
305
+ process.env.SAGE_PRIVY_LOGIN_MODE = 'web';
306
+ ui.info('Using web-based authentication via relay...');
312
307
  }
313
308
 
314
309
  const walletManager = new PrivyAuthWalletManager();
@@ -8,7 +8,8 @@
8
8
  const { ethers } = require('ethers');
9
9
 
10
10
  // Default RPC URL
11
- const DEFAULT_RPC = process.env.RPC_URL
11
+ const DEFAULT_RPC = process.env.SAGE_RPC_URL
12
+ || process.env.RPC_URL
12
13
  || process.env.BASE_SEPOLIA_RPC
13
14
  || 'https://base-sepolia.publicnode.com';
14
15
 
package/dist/cli/index.js CHANGED
@@ -19,6 +19,11 @@ if (!process.env.CONTRACT_ARTIFACTS_ROOT) {
19
19
  }
20
20
  }
21
21
 
22
+ // Always trust SAGE_RPC_URL over RPC_URL when provided
23
+ if (process.env.SAGE_RPC_URL) {
24
+ process.env.RPC_URL = process.env.SAGE_RPC_URL;
25
+ }
26
+
22
27
  // Filter out deprecated Lit SDK warning immediately
23
28
  const realStderrWrite = process.stderr.write.bind(process.stderr);
24
29
  process.stderr.write = (chunk, encoding, callback) => {
@@ -82,7 +82,9 @@ class PrivyAuthWalletManager {
82
82
  try {
83
83
  const relayEnabled = process.env.SAGE_DISABLE_PRIVY_RELAY !== '1';
84
84
  const hasAppSecret = !!this.appSecret;
85
- const preferSessionFlow = relayEnabled && !hasAppSecret;
85
+ const mode = String(process.env.SAGE_PRIVY_LOGIN_MODE || '').trim().toLowerCase();
86
+ const forceRelay = relayEnabled && mode !== 'local' && mode !== 'oauth';
87
+ const preferSessionFlow = forceRelay;
86
88
 
87
89
  // Try cached credentials first
88
90
  const cached = readPrivyCredentials();
@@ -140,7 +142,6 @@ class PrivyAuthWalletManager {
140
142
 
141
143
  // No valid cached credentials - need OAuth login
142
144
  console.log('\nNo cached Privy credentials found.');
143
- const mode = String(process.env.SAGE_PRIVY_LOGIN_MODE || '').trim().toLowerCase();
144
145
  const useSessionFlow = preferSessionFlow || mode === 'web' || mode === 'session';
145
146
  const useDeviceCodeFlow = !preferSessionFlow && (mode === 'device' || mode === 'device_code');
146
147
 
@@ -58,7 +58,7 @@ const CHAIN_CONFIGS = {
58
58
  * Used for resolution precedence.
59
59
  */
60
60
  const ENV_VARS = {
61
- rpcUrl: ['RPC_URL', 'BASE_SEPOLIA_RPC', 'BASE_RPC_URL'],
61
+ rpcUrl: ['SAGE_RPC_URL', 'RPC_URL', 'BASE_SEPOLIA_RPC', 'BASE_RPC_URL'],
62
62
  chainId: ['CHAIN_ID', 'BASE_CHAIN_ID'],
63
63
  subgraphUrl: ['SUBGRAPH_URL', 'SAGE_SUBGRAPH_URL', 'NEXT_PUBLIC_GRAPH_ENDPOINT', 'NEXT_PUBLIC_SUBGRAPH_URL']
64
64
  };
@@ -121,6 +121,9 @@ class ConfigManager {
121
121
  if (fs.existsSync(sageEnv)) {
122
122
  dotenv.config({ path: sageEnv });
123
123
  }
124
+ if (process.env.SAGE_RPC_URL) {
125
+ process.env.RPC_URL = process.env.SAGE_RPC_URL;
126
+ }
124
127
  } catch (_) {
125
128
  // dotenv not available, skip
126
129
  }
@@ -105,6 +105,7 @@ const addressMap = z.record(
105
105
  */
106
106
  const ipfsConfig = z.object({
107
107
  gateway: z.string().url().optional(),
108
+ gateways: z.array(z.string()).optional(),
108
109
  pinataApiKey: z.string().optional(),
109
110
  pinataSecretKey: z.string().optional(),
110
111
  pinataJwt: z.string().optional(),
@@ -2,6 +2,12 @@ const path = require('path');
2
2
  const ui = require('../../utils/cli-ui');
3
3
 
4
4
  const DEFAULT_GATEWAY = 'https://ipfs.dev.sageprotocol.io/ipfs';
5
+ const FALLBACK_GATEWAYS = [
6
+ 'https://dweb.link/ipfs',
7
+ 'https://nftstorage.link/ipfs',
8
+ 'https://ipfs.io/ipfs',
9
+ 'https://gateway.pinata.cloud/ipfs'
10
+ ];
5
11
  const DEFAULT_WORKER_BASE = 'https://api.sageprotocol.io';
6
12
  const DEFAULT_WORKER_CHALLENGE_PATH = '/ipfs/auth/challenge';
7
13
  const DEFAULT_WORKER_UPLOAD_PATH = '/ipfs/upload';
@@ -87,6 +93,7 @@ function createIpfsOnboarding({
87
93
 
88
94
  if (!quiet) {
89
95
  ui.header('Sage IPFS pinning setup', 'Configure your gateway and pinning providers so uploads work out of the box.');
96
+ ui.output(`Gateway fallbacks: ${FALLBACK_GATEWAYS.join(', ')}`);
90
97
  }
91
98
 
92
99
  let scope = initialScope && ['project', 'global'].includes(initialScope) ? initialScope : null;
@@ -332,6 +339,10 @@ function createIpfsOnboarding({
332
339
  const payload = {};
333
340
  if (gateway !== undefined) {
334
341
  payload.gateway = ensureString(gateway) || null;
342
+ if (payload.gateway) {
343
+ const fallback = FALLBACK_GATEWAYS.filter((g) => g !== payload.gateway);
344
+ payload.gateways = fallback;
345
+ }
335
346
  }
336
347
  payload.warmGateway = !!warmGateway;
337
348
  if (provider) payload.provider = provider;