dexe-mcp 0.20.1 → 0.22.0

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.
Files changed (111) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +2 -1
  3. package/dexe-plugin/skills/dexe-create-dao/SKILL.md +8 -0
  4. package/dexe-plugin/skills/dexe-create-proposal/SKILL.md +47 -8
  5. package/dexe-plugin/skills/dexe-otc/SKILL.md +5 -0
  6. package/dexe-plugin/skills/dexe-vote-execute/SKILL.md +16 -3
  7. package/dist/config.d.ts +7 -0
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +49 -9
  10. package/dist/config.js.map +1 -1
  11. package/dist/env/schema.d.ts +27 -6
  12. package/dist/env/schema.d.ts.map +1 -1
  13. package/dist/env/schema.js +36 -6
  14. package/dist/env/schema.js.map +1 -1
  15. package/dist/index.js +35 -7
  16. package/dist/index.js.map +1 -1
  17. package/dist/lib/avatarUpload.d.ts +57 -0
  18. package/dist/lib/avatarUpload.d.ts.map +1 -0
  19. package/dist/lib/avatarUpload.js +95 -0
  20. package/dist/lib/avatarUpload.js.map +1 -0
  21. package/dist/lib/errors.d.ts +33 -0
  22. package/dist/lib/errors.d.ts.map +1 -0
  23. package/dist/lib/errors.js +65 -0
  24. package/dist/lib/errors.js.map +1 -0
  25. package/dist/lib/params.d.ts +8 -0
  26. package/dist/lib/params.d.ts.map +1 -0
  27. package/dist/lib/params.js +14 -0
  28. package/dist/lib/params.js.map +1 -0
  29. package/dist/lib/proposalBuilders.d.ts +30 -2
  30. package/dist/lib/proposalBuilders.d.ts.map +1 -1
  31. package/dist/lib/proposalBuilders.js +583 -17
  32. package/dist/lib/proposalBuilders.js.map +1 -1
  33. package/dist/lib/requireEnv.d.ts.map +1 -1
  34. package/dist/lib/requireEnv.js +7 -4
  35. package/dist/lib/requireEnv.js.map +1 -1
  36. package/dist/lib/signer.d.ts.map +1 -1
  37. package/dist/lib/signer.js +7 -2
  38. package/dist/lib/signer.js.map +1 -1
  39. package/dist/lib/txWait.d.ts +28 -0
  40. package/dist/lib/txWait.d.ts.map +1 -0
  41. package/dist/lib/txWait.js +63 -0
  42. package/dist/lib/txWait.js.map +1 -0
  43. package/dist/lib/units.d.ts +32 -0
  44. package/dist/lib/units.d.ts.map +1 -0
  45. package/dist/lib/units.js +64 -0
  46. package/dist/lib/units.js.map +1 -0
  47. package/dist/rpc.d.ts +29 -1
  48. package/dist/rpc.d.ts.map +1 -1
  49. package/dist/rpc.js +86 -22
  50. package/dist/rpc.js.map +1 -1
  51. package/dist/tools/dao.d.ts.map +1 -1
  52. package/dist/tools/dao.js +16 -12
  53. package/dist/tools/dao.js.map +1 -1
  54. package/dist/tools/daoCreate.d.ts.map +1 -1
  55. package/dist/tools/daoCreate.js +37 -7
  56. package/dist/tools/daoCreate.js.map +1 -1
  57. package/dist/tools/flow.d.ts +32 -1
  58. package/dist/tools/flow.d.ts.map +1 -1
  59. package/dist/tools/flow.js +405 -120
  60. package/dist/tools/flow.js.map +1 -1
  61. package/dist/tools/gate.js +2 -2
  62. package/dist/tools/gov.d.ts.map +1 -1
  63. package/dist/tools/gov.js +17 -4
  64. package/dist/tools/gov.js.map +1 -1
  65. package/dist/tools/inbox.d.ts.map +1 -1
  66. package/dist/tools/inbox.js +4 -2
  67. package/dist/tools/inbox.js.map +1 -1
  68. package/dist/tools/ipfs.d.ts.map +1 -1
  69. package/dist/tools/ipfs.js +43 -47
  70. package/dist/tools/ipfs.js.map +1 -1
  71. package/dist/tools/operationalContext.d.ts.map +1 -1
  72. package/dist/tools/operationalContext.js +39 -5
  73. package/dist/tools/operationalContext.js.map +1 -1
  74. package/dist/tools/otc.d.ts.map +1 -1
  75. package/dist/tools/otc.js +53 -13
  76. package/dist/tools/otc.js.map +1 -1
  77. package/dist/tools/predict.d.ts.map +1 -1
  78. package/dist/tools/predict.js +7 -4
  79. package/dist/tools/predict.js.map +1 -1
  80. package/dist/tools/proposal.d.ts.map +1 -1
  81. package/dist/tools/proposal.js +8 -4
  82. package/dist/tools/proposal.js.map +1 -1
  83. package/dist/tools/proposalBuild.d.ts +1 -0
  84. package/dist/tools/proposalBuild.d.ts.map +1 -1
  85. package/dist/tools/proposalBuild.js +2 -1
  86. package/dist/tools/proposalBuild.js.map +1 -1
  87. package/dist/tools/proposalBuildComplex.d.ts +7 -0
  88. package/dist/tools/proposalBuildComplex.d.ts.map +1 -1
  89. package/dist/tools/proposalBuildComplex.js +7 -7
  90. package/dist/tools/proposalBuildComplex.js.map +1 -1
  91. package/dist/tools/proposalBuildInternal.d.ts +21 -0
  92. package/dist/tools/proposalBuildInternal.d.ts.map +1 -1
  93. package/dist/tools/proposalBuildInternal.js +1 -1
  94. package/dist/tools/proposalBuildInternal.js.map +1 -1
  95. package/dist/tools/proposalBuildMore.d.ts +2 -0
  96. package/dist/tools/proposalBuildMore.d.ts.map +1 -1
  97. package/dist/tools/proposalBuildMore.js +2 -2
  98. package/dist/tools/proposalBuildMore.js.map +1 -1
  99. package/dist/tools/read.d.ts.map +1 -1
  100. package/dist/tools/read.js +25 -16
  101. package/dist/tools/read.js.map +1 -1
  102. package/dist/tools/subgraph.d.ts.map +1 -1
  103. package/dist/tools/subgraph.js +5 -3
  104. package/dist/tools/subgraph.js.map +1 -1
  105. package/dist/tools/txSend.d.ts.map +1 -1
  106. package/dist/tools/txSend.js +46 -6
  107. package/dist/tools/txSend.js.map +1 -1
  108. package/dist/tools/vote.d.ts.map +1 -1
  109. package/dist/tools/vote.js +7 -4
  110. package/dist/tools/vote.js.map +1 -1
  111. package/package.json +1 -1
@@ -2,7 +2,10 @@ import { z } from "zod";
2
2
  import { Contract, Interface } from "ethers";
3
3
  import { RpcProvider } from "../rpc.js";
4
4
  import { multicall } from "../lib/multicall.js";
5
- import { PinataClient, fetchIpfs } from "../lib/ipfs.js";
5
+ import { PinataClient, fetchIpfs, toCidV1 } from "../lib/ipfs.js";
6
+ import { buildAvatarUrl, pinAvatarFromInput } from "../lib/avatarUpload.js";
7
+ import { checkAvatarCidBytes } from "../lib/imageSniff.js";
8
+ import { resolveGateways } from "./ipfs.js";
6
9
  import { qrFallbackUrl, wcQrBlocks } from "../lib/qr.js";
7
10
  import { markdownToSlate } from "../lib/markdownToSlate.js";
8
11
  import { resolveChain } from "../config.js";
@@ -12,14 +15,20 @@ import { AddressBook, CONTRACT_NAMES } from "../lib/addresses.js";
12
15
  import { classifyTreasuryActions, quorumPctFromRaw, judgeQuorum, treasuryExecuteAdvisory, TREASURY_RISK_ADVISORY, } from "../lib/quorumRisk.js";
13
16
  import { GET_PROPOSALS_FRAGMENT, decodeProposalView } from "../lib/govProposalView.js";
14
17
  import { resolveControllingHoldersVotedFor } from "../lib/controllingVoters.js";
15
- import { PROPOSAL_BUILDERS, FLOW_PROPOSAL_TYPES } from "../lib/proposalBuilders.js";
18
+ import { PROPOSAL_BUILDERS, INTERNAL_PROPOSAL_BUILDERS, OFFCHAIN_FLOW_TYPES, FLOW_PROPOSAL_TYPES, } from "../lib/proposalBuilders.js";
19
+ import { GOV_VALIDATORS_CREATE_ABI } from "./proposalBuild.js";
16
20
  import { PROPOSAL_CATALOG } from "../lib/proposalCatalog.js";
17
21
  import { checkProposalMetadata, proposalStateName } from "../lib/preflight.js";
22
+ import { waitWithTimeout, assertReceiptSuccess, txWaitTimeoutMs } from "../lib/txWait.js";
23
+ import { toActionableError } from "../lib/errors.js";
24
+ import { parseAmount, formatAmount } from "../lib/units.js";
18
25
  // ---------- ABI fragments ----------
19
26
  const ERC20_ABI = new Interface([
20
27
  "function balanceOf(address) view returns (uint256)",
21
28
  "function allowance(address owner, address spender) view returns (uint256)",
22
29
  "function approve(address spender, uint256 amount) returns (bool)",
30
+ "function decimals() view returns (uint8)",
31
+ "function symbol() view returns (string)",
23
32
  ]);
24
33
  const GOV_POOL_ABI = new Interface([
25
34
  "function getHelperContracts() view returns (address settings, address userKeeper, address validators, address poolRegistry, address votePower)",
@@ -55,6 +64,21 @@ function ok(data) {
55
64
  function bigintReplacer(_k, v) {
56
65
  return typeof v === "bigint" ? v.toString() : v;
57
66
  }
67
+ /**
68
+ * Uniform composite-failure response (R7): failed step + actionable error +
69
+ * ledger of steps that already landed (gas spent) + how to resume.
70
+ */
71
+ export function flowFailureResult(result, extra) {
72
+ return {
73
+ content: [
74
+ {
75
+ type: "text",
76
+ text: JSON.stringify({ mode: "failed", ...(extra ?? {}), failure: result.failure, steps: result.steps }, bigintReplacer, 2),
77
+ },
78
+ ],
79
+ isError: true,
80
+ };
81
+ }
58
82
  function makeTxPayload(to, iface, method, args, chainId, description, value) {
59
83
  return {
60
84
  to,
@@ -208,14 +232,18 @@ async function resolvePrereqs(rpc, govPool, user, config, chainId) {
208
232
  const [balance, ownedBalance] = res2[2].value;
209
233
  depositedPower = balance - ownedBalance;
210
234
  }
211
- // Batch 3: ERC20 balance + allowance
235
+ // Batch 3: ERC20 balance + allowance + display metadata (best-effort)
212
236
  const batch3 = [
213
237
  { target: tokenAddress, iface: ERC20_ABI, method: "balanceOf", args: [user] },
214
238
  { target: tokenAddress, iface: ERC20_ABI, method: "allowance", args: [user, userKeeper] },
239
+ { target: tokenAddress, iface: ERC20_ABI, method: "decimals", args: [], allowFailure: true },
240
+ { target: tokenAddress, iface: ERC20_ABI, method: "symbol", args: [], allowFailure: true },
215
241
  ];
216
242
  const res3 = await multicall(provider, batch3);
217
243
  const walletBalance = res3[0].success ? res3[0].value : 0n;
218
244
  const currentAllowance = res3[1].success ? res3[1].value : 0n;
245
+ const tokenDecimals = res3[2].success ? Number(res3[2].value) : 18;
246
+ const tokenSymbol = res3[3].success ? String(res3[3].value) : "";
219
247
  return {
220
248
  userKeeper,
221
249
  settings,
@@ -225,6 +253,8 @@ async function resolvePrereqs(rpc, govPool, user, config, chainId) {
225
253
  depositedPower,
226
254
  minVotesForCreating: defaultSettings.minVotesForCreating,
227
255
  minVotesForVoting: defaultSettings.minVotesForVoting,
256
+ tokenDecimals,
257
+ tokenSymbol,
228
258
  };
229
259
  }
230
260
  /**
@@ -331,29 +361,55 @@ export async function sendOrCollect(signer, payloads, opts) {
331
361
  const wallet = sg.ok;
332
362
  const cfg = signer.getConfig();
333
363
  for (const p of payloads) {
334
- // Same B6/B7/B10 broadcast guards as dexe_tx_send. B9 simulation is skipped:
335
- // these payloads are an ordered, *dependent* sequence, so simming a later
336
- // step against pre-sequence state would falsely revert. A BroadcastGuardError
337
- // aborts the flow before the offending send (gas spent only on prior steps).
338
- await runBroadcastGuards({
339
- to: p.to,
340
- data: p.data,
341
- value: p.value,
342
- chainId: Number(p.chainId),
343
- from: wallet.address,
344
- }, cfg, { skipSimulation: true });
345
- const tx = await signer.withBroadcastLock(Number(p.chainId), () => wallet.sendTransaction({
346
- to: p.to,
347
- data: p.data,
348
- value: BigInt(p.value),
349
- chainId: BigInt(p.chainId),
350
- }));
351
- const receipt = await tx.wait(1);
352
- steps.push({
353
- label: p.description,
354
- skipped: false,
355
- txHash: receipt?.hash ?? tx.hash,
356
- });
364
+ // Any step failing mid-sequence: STOP (dependent steps must not run on top
365
+ // of unchanged state R3), report which steps already landed (gas spent),
366
+ // and tell the caller how to resume (R7). Composites re-check completed
367
+ // work (allowance, deposited power, proposal state) on re-run, so "fix the
368
+ // cause and re-run this same call" is the correct resume for every flow.
369
+ try {
370
+ // Same B6/B7/B10 broadcast guards as dexe_tx_send. B9 simulation is skipped:
371
+ // these payloads are an ordered, *dependent* sequence, so simming a later
372
+ // step against pre-sequence state would falsely revert. A BroadcastGuardError
373
+ // aborts the flow before the offending send (gas spent only on prior steps).
374
+ await runBroadcastGuards({
375
+ to: p.to,
376
+ data: p.data,
377
+ value: p.value,
378
+ chainId: Number(p.chainId),
379
+ from: wallet.address,
380
+ }, cfg, { skipSimulation: true });
381
+ const tx = await signer.withBroadcastLock(Number(p.chainId), () => wallet.sendTransaction({
382
+ to: p.to,
383
+ data: p.data,
384
+ value: BigInt(p.value),
385
+ chainId: BigInt(p.chainId),
386
+ }));
387
+ const receipt = await waitWithTimeout(tx, { timeoutMs: txWaitTimeoutMs() });
388
+ assertReceiptSuccess(receipt, p.description);
389
+ steps.push({
390
+ label: p.description,
391
+ skipped: false,
392
+ txHash: receipt?.hash ?? tx.hash,
393
+ });
394
+ }
395
+ catch (e) {
396
+ const landed = steps.filter((s) => s.txHash);
397
+ const actionable = toActionableError(e, p.description);
398
+ return {
399
+ mode: "failed",
400
+ steps,
401
+ failure: {
402
+ failedStep: p.description,
403
+ error: actionable.message,
404
+ landedSteps: landed,
405
+ resume: landed.length > 0
406
+ ? `${landed.length} earlier step(s) already landed on-chain (see landedSteps txHashes). ` +
407
+ "Fix the cause above and re-run this same call — already-satisfied steps (approve / deposit / vote) " +
408
+ "are detected on-chain and skipped automatically."
409
+ : "No steps landed on-chain. Fix the cause above and re-run this same call.",
410
+ },
411
+ };
412
+ }
357
413
  }
358
414
  return { mode: "executed", steps };
359
415
  }
@@ -372,6 +428,24 @@ export async function runProposalCreate(inputRaw, deps) {
372
428
  ...inputRaw,
373
429
  };
374
430
  const { ctx, signer, rpc } = deps;
431
+ // Off-chain proposal types live on the DeXe backend, not on any contract —
432
+ // reject with the exact alternative flow instead of a dead-end.
433
+ if (OFFCHAIN_FLOW_TYPES.includes(input.proposalType)) {
434
+ const buildTool = `dexe_proposal_build_${input.proposalType}`;
435
+ return err(`proposalType '${input.proposalType}' is an OFF-CHAIN proposal — it is created on the DeXe backend ` +
436
+ `(api.dexe.io), not on-chain, so this composite cannot broadcast it. Flow instead:\n` +
437
+ `1) ${buildTool} → returns the ready-to-send HTTP request (JSON:API body).\n` +
438
+ `2) Authenticate: dexe_auth_request_nonce (get the message), sign it with the user's wallet, ` +
439
+ `dexe_auth_login_request (exchange for access_token).\n` +
440
+ `3) Send the request with 'Authorization: Bearer <access_token>'.\n` +
441
+ `Note: the backend indexes BSC mainnet (56) DAOs only.`);
442
+ }
443
+ // Internal proposal types are created on GovValidators (validators-only
444
+ // voting, no token deposit) — a different single-tx path.
445
+ const internalBuilder = INTERNAL_PROPOSAL_BUILDERS[input.proposalType];
446
+ if (internalBuilder) {
447
+ return runInternalProposalCreate(input, deps, internalBuilder);
448
+ }
375
449
  if (!ctx.config.pinataJwt)
376
450
  return err(pinataUploadHint("to create a proposal"));
377
451
  const user = input.user ?? (signer.hasSigner() ? signer.getAddress() : undefined);
@@ -387,7 +461,10 @@ export async function runProposalCreate(inputRaw, deps) {
387
461
  prereqs = await resolvePrereqs(rpc, govPool, user, ctx.config, chainId);
388
462
  }
389
463
  catch (e) {
390
- return err(`Failed to resolve prerequisites: ${e instanceof Error ? e.message : String(e)}`);
464
+ const a = toActionableError(e, "resolve DAO prerequisites");
465
+ return err(a.slug
466
+ ? a.message
467
+ : `${a.message}\nIf this repeats, verify the govPool address is a DeXe GovPool on chain ${chainId} (dexe_dao_info) — a wrong address or chain yields exactly this read failure.`);
391
468
  }
392
469
  // Mode 6 guard: the auto-approve targets the UserKeeper (which does
393
470
  // transferFrom on deposit). If the resolved keeper collapses onto the
@@ -399,7 +476,12 @@ export async function runProposalCreate(inputRaw, deps) {
399
476
  // Step 2: check creation threshold
400
477
  const totalAvailable = prereqs.walletBalance + prereqs.depositedPower;
401
478
  if (prereqs.minVotesForCreating > 0n && totalAvailable < prereqs.minVotesForCreating) {
402
- return err(`Insufficient tokens for proposal creation. Need ${prereqs.minVotesForCreating} but have ${totalAvailable} (wallet: ${prereqs.walletBalance}, deposited: ${prereqs.depositedPower}).`);
479
+ const d = prereqs.tokenDecimals;
480
+ const sym = prereqs.tokenSymbol;
481
+ return err(`Insufficient tokens to create a proposal on this DAO. The DAO requires ${formatAmount(prereqs.minVotesForCreating, d, sym)} ` +
482
+ `but ${user} has ${formatAmount(totalAvailable, d, sym)} total (wallet ${formatAmount(prereqs.walletBalance, d, sym)}, ` +
483
+ `deposited ${formatAmount(prereqs.depositedPower, d, sym)}). ` +
484
+ `Next step: acquire more ${sym || "gov tokens"} (token ${prereqs.tokenAddress}), or have a holder with enough tokens create the proposal.`);
403
485
  }
404
486
  // Step 3: build actions + metadata based on type
405
487
  let actionsOnFor;
@@ -467,7 +549,7 @@ export async function runProposalCreate(inputRaw, deps) {
467
549
  " to merge partial update — refusing to broadcast (would blank unspecified fields). " +
468
550
  (currentMetaFetchError ? `Last error: ${currentMetaFetchError}. ` : "") +
469
551
  "Either set DEXE_IPFS_GATEWAY to a reachable gateway or pass all fields explicitly " +
470
- "(newDaoName, newWebsiteUrl, newDaoDescription, newSocialLinks, newAvatarCID/newAvatarFileName).");
552
+ "(newDaoName, newWebsiteUrl, newDaoDescription, newSocialLinks, newAvatarPath or newAvatarCID/newAvatarFileName).");
471
553
  }
472
554
  // Decide which description body to use:
473
555
  // - if caller passed newDaoDescription (or generic description), upload fresh
@@ -488,14 +570,35 @@ export async function runProposalCreate(inputRaw, deps) {
488
570
  socialLinks: input.newSocialLinks ?? (Array.isArray(currentMeta.socialLinks) ? currentMeta.socialLinks : []),
489
571
  documents: Array.isArray(currentMeta.documents) ? currentMeta.documents : [],
490
572
  };
491
- if (input.newAvatarCID) {
492
- daoMeta.avatarCID = input.newAvatarCID;
493
- daoMeta.avatarFileName = input.newAvatarFileName ?? "avatar.jpeg";
494
- // dweb.link + path-style resolves directory pins reliably across
495
- // gateways. The frontend rebuilds the URL itself (see
496
- // parseAvatarFromIpfsResponse) so the field is informational only,
497
- // but the CID + filename pair is load-bearing.
498
- daoMeta.avatarUrl = `https://${input.newAvatarCID}.ipfs.dweb.link/${daoMeta.avatarFileName}`;
573
+ if ((input.newAvatarPath || input.newAvatarBase64) && input.newAvatarCID) {
574
+ return err("Pass either `newAvatarCID` or `newAvatarPath`/`newAvatarBase64`, not both.");
575
+ }
576
+ if (input.newAvatarPath || input.newAvatarBase64) {
577
+ // One-call avatar rotation: read + validate (magic bytes) + pin the
578
+ // image server-side. The agent should never read image files itself.
579
+ const pinned = await pinAvatarFromInput({
580
+ filePath: input.newAvatarPath,
581
+ base64: input.newAvatarBase64,
582
+ pinata,
583
+ });
584
+ daoMeta.avatarCID = pinned.avatarCID;
585
+ daoMeta.avatarFileName = pinned.avatarFileName;
586
+ daoMeta.avatarUrl = pinned.avatarUrl;
587
+ }
588
+ else if (input.newAvatarCID) {
589
+ // By-reference CID — the local byte gate never saw these bytes, so
590
+ // best-effort fetch + sniff (hard-block only on confirmed non-raster).
591
+ const avatarCidV1 = toCidV1(input.newAvatarCID);
592
+ const avatarFileName = input.newAvatarFileName ?? "avatar.jpeg";
593
+ const check = await checkAvatarCidBytes(avatarCidV1, avatarFileName, resolveGateways(ctx));
594
+ if (!check.ok)
595
+ return err(check.error ?? "newAvatarCID failed raster validation");
596
+ daoMeta.avatarCID = avatarCidV1;
597
+ daoMeta.avatarFileName = avatarFileName;
598
+ // The frontend rebuilds the URL itself (parseAvatarFromIpfsResponse)
599
+ // so the field is informational, but the CID + filename pair is
600
+ // load-bearing.
601
+ daoMeta.avatarUrl = buildAvatarUrl(avatarCidV1, avatarFileName);
499
602
  }
500
603
  const daoMetaRes = await pinata.pinJson(daoMeta, { name: `dao-meta:${govPool.slice(0, 10)}` });
501
604
  const newDescriptionURL = `ipfs://${daoMetaRes.cid}`;
@@ -559,7 +662,7 @@ export async function runProposalCreate(inputRaw, deps) {
559
662
  built = await builder.build(parsed.data, { ctx, govPool, chainId });
560
663
  }
561
664
  catch (e) {
562
- return err(`Failed to build ${input.proposalType} actions: ${e instanceof Error ? e.message : String(e)}`);
665
+ return err(toActionableError(e, `build ${input.proposalType} actions`).message);
563
666
  }
564
667
  actionsOnFor = built.actionsOnFor.map((a) => ({
565
668
  executor: a.executor,
@@ -588,14 +691,28 @@ export async function runProposalCreate(inputRaw, deps) {
588
691
  // Step 5: build tx payloads
589
692
  const payloads = [];
590
693
  const skippedSteps = [];
591
- // Determine how much to deposit
592
- const voteAmount = input.voteAmount ? BigInt(input.voteAmount) : prereqs.depositedPower + prereqs.walletBalance;
694
+ // Determine how much to deposit. voteAmount accepts raw wei (digits-only)
695
+ // or human units with a decimal point, scaled by the gov token's decimals.
696
+ let voteAmount;
697
+ try {
698
+ voteAmount = input.voteAmount
699
+ ? parseAmount(input.voteAmount, prereqs.tokenDecimals)
700
+ : prereqs.depositedPower + prereqs.walletBalance;
701
+ }
702
+ catch (e) {
703
+ return err(e instanceof Error ? e.message : String(e));
704
+ }
593
705
  if (voteAmount === 0n) {
594
- return err("No voting power available (wallet + deposited = 0). Deposit tokens first.");
706
+ return err(`No voting power available — ${user} holds 0 ${prereqs.tokenSymbol || "gov tokens"} (wallet + deposited). ` +
707
+ `Acquire the DAO's gov token (${prereqs.tokenAddress}) first, then re-run.`);
595
708
  }
596
709
  const needDeposit = voteAmount > prereqs.depositedPower ? voteAmount - prereqs.depositedPower : 0n;
597
710
  if (needDeposit > prereqs.walletBalance) {
598
- return err(`Need to deposit ${needDeposit} but wallet only has ${prereqs.walletBalance}. Missing ${needDeposit - prereqs.walletBalance}.`);
711
+ const d = prereqs.tokenDecimals;
712
+ const sym = prereqs.tokenSymbol;
713
+ return err(`Not enough tokens: voting with ${formatAmount(voteAmount, d, sym)} needs a deposit of ${formatAmount(needDeposit, d, sym)} ` +
714
+ `but the wallet only holds ${formatAmount(prereqs.walletBalance, d, sym)}. ` +
715
+ `Next step: lower voteAmount to at most ${formatAmount(prereqs.depositedPower + prereqs.walletBalance, d, sym)}, or acquire more tokens.`);
599
716
  }
600
717
  // Approve (if needed)
601
718
  if (needDeposit > 0n && prereqs.currentAllowance < needDeposit) {
@@ -607,45 +724,25 @@ export async function runProposalCreate(inputRaw, deps) {
607
724
  else {
608
725
  skippedSteps.push({ label: "ERC20.approve", skipped: true, reason: "Allowance sufficient" });
609
726
  }
610
- // Build GovPool calls to batch via multicall
611
- const govPoolCalls = [];
612
- // Deposit (if needed)
727
+ // Deposit (if needed) a SEPARATE tx, never bundled. Newly deployed
728
+ // pools ship with SphereX protection that rejects the old
729
+ // multicall([deposit, createProposalAndVote]) wrap with
730
+ // "SphereX error: disallowed tx pattern" (verified live on chain 97,
731
+ // v0.22). Sequential txs pass, and the failure ledger makes the
732
+ // two-step sequence safely resumable.
613
733
  if (needDeposit > 0n) {
614
- govPoolCalls.push(GOV_POOL_ABI.encodeFunctionData("deposit", [needDeposit, []]));
734
+ payloads.push(makeTxPayload(govPool, GOV_POOL_ABI, "deposit", [needDeposit, []], chainId, `GovPool.deposit(${needDeposit})`));
615
735
  }
616
736
  else {
617
737
  skippedSteps.push({ label: "GovPool.deposit", skipped: true, reason: "Sufficient deposited power" });
618
738
  }
619
- // createProposalAndVote
620
739
  const actionsForTuple = actionsOnFor.map(a => [a.executor, a.value, a.data]);
621
- govPoolCalls.push(GOV_POOL_ABI.encodeFunctionData("createProposalAndVote", [
622
- descriptionURL,
623
- actionsForTuple,
624
- [], // actionsOnAgainst
625
- voteAmount,
626
- input.voteNftIds.map(id => BigInt(id)),
627
- ]));
628
- // Wrap in multicall if >1 call, otherwise single tx
629
- if (govPoolCalls.length > 1) {
630
- payloads.push({
631
- to: govPool,
632
- data: GOV_POOL_ABI.encodeFunctionData("multicall", [govPoolCalls]),
633
- value: "0",
634
- chainId,
635
- description: `GovPool.multicall([deposit, createProposalAndVote])`,
636
- });
637
- }
638
- else {
639
- payloads.push({
640
- to: govPool,
641
- data: govPoolCalls[0],
642
- value: "0",
643
- chainId,
644
- description: `GovPool.createProposalAndVote("${input.title}")`,
645
- });
646
- }
740
+ payloads.push(makeTxPayload(govPool, GOV_POOL_ABI, "createProposalAndVote", [descriptionURL, actionsForTuple, [], voteAmount, input.voteNftIds.map(id => BigInt(id))], chainId, `GovPool.createProposalAndVote("${input.title}")`));
647
741
  // Step 6: send or return
648
742
  const result = await sendOrCollect(signer, payloads, { dryRun: input.dryRun, chainId, wc: deps.wc });
743
+ if (result.mode === "failed") {
744
+ return flowFailureResult(result, { descriptionURL, proposalMetadataCID: proposalMetaRes.cid });
745
+ }
649
746
  // Phase 3: record a broadcast proposal so dexe_context surfaces it next
650
747
  // session. Best-effort — a state-write error never breaks the broadcast.
651
748
  if (result.mode === "executed" && deps.state) {
@@ -680,24 +777,147 @@ export async function runProposalCreate(inputRaw, deps) {
680
777
  ...(result.pairing ? { pairing: result.pairing } : {}),
681
778
  }), result.pairingContent);
682
779
  }
780
+ /**
781
+ * v0.22 — internal-proposal path of `dexe_proposal_create`. Internal proposals
782
+ * (change_validator_balances / change_validator_settings / monthly_withdraw /
783
+ * offchain_internal_proposal) are created on GovValidators via
784
+ * `createInternalProposal(uint8, descriptionURL, bytes)` — validators vote with
785
+ * their own balances, so there is no approve/deposit sequence. Only a current
786
+ * validator can create one; the response notes that requirement.
787
+ */
788
+ async function runInternalProposalCreate(inputRaw, deps, builder) {
789
+ const input = { proposalType: "custom", description: "", ...inputRaw };
790
+ const { ctx, signer, rpc } = deps;
791
+ if (!ctx.config.pinataJwt)
792
+ return err(pinataUploadHint("to create an internal proposal"));
793
+ const parsed = builder.schema.safeParse(input.params ?? {});
794
+ if (!parsed.success) {
795
+ return err(`Invalid params for proposalType '${input.proposalType}': ` +
796
+ parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; "));
797
+ }
798
+ let built;
799
+ try {
800
+ built = builder.build(parsed.data);
801
+ }
802
+ catch (e) {
803
+ return err(toActionableError(e, `build ${input.proposalType}`).message);
804
+ }
805
+ const chain = resolveChain(ctx.config, input.chainId);
806
+ const chainId = chain.chainId;
807
+ const govPool = input.govPool;
808
+ const pr = rpc.tryProvider(chainId);
809
+ if ("error" in pr)
810
+ return err(`${pr.error}\n${pr.remediation}`);
811
+ const provider = pr.ok;
812
+ // W10: same registered-pool check as the external flow.
813
+ try {
814
+ await assertRegisteredGovPool(provider, rpc, ctx.config, chainId, govPool);
815
+ }
816
+ catch (e) {
817
+ return err(e instanceof Error ? e.message : String(e));
818
+ }
819
+ // Resolve the GovValidators helper from the pool.
820
+ let validators;
821
+ try {
822
+ const res = await multicall(provider, [
823
+ { target: govPool, iface: GOV_POOL_ABI, method: "getHelperContracts", args: [] },
824
+ ]);
825
+ if (!res[0].success)
826
+ throw new Error(res[0].error ?? "getHelperContracts reverted");
827
+ const helpers = res[0].value;
828
+ validators = helpers[2];
829
+ }
830
+ catch (e) {
831
+ return err(toActionableError(e, "resolve GovValidators").message);
832
+ }
833
+ // Metadata shape mirrors dexe_proposal_build_change_validator_* exactly
834
+ // (internal metadata carries no isMeta field).
835
+ const pinata = new PinataClient(ctx.config.pinataJwt);
836
+ const proposalMeta = {
837
+ proposalName: input.title,
838
+ proposalDescription: JSON.stringify(markdownToSlate(input.description)),
839
+ category: built.category,
840
+ ...built.metadataExtra,
841
+ };
842
+ let cid;
843
+ try {
844
+ const res = await pinata.pinJson(proposalMeta, { name: `proposal:${input.title.slice(0, 30)}` });
845
+ cid = res.cid;
846
+ }
847
+ catch (e) {
848
+ return err(toActionableError(e, "upload internal-proposal metadata").message);
849
+ }
850
+ const descriptionURL = `ipfs://${cid}`;
851
+ const validatorsIface = new Interface(GOV_VALIDATORS_CREATE_ABI);
852
+ const payloads = [
853
+ makeTxPayload(validators, validatorsIface, "createInternalProposal", [built.internalType, descriptionURL, built.data], chainId, `GovValidators.createInternalProposal(${built.summary})`),
854
+ ];
855
+ const result = await sendOrCollect(signer, payloads, { dryRun: input.dryRun, chainId, wc: deps.wc });
856
+ if (result.mode === "failed") {
857
+ return flowFailureResult(result, {
858
+ proposalKind: "internal",
859
+ descriptionURL,
860
+ note: "Internal proposals can only be created by a CURRENT validator of this DAO — a non-validator sender reverts.",
861
+ });
862
+ }
863
+ if (result.mode === "executed" && deps.state) {
864
+ try {
865
+ const txHash = [...result.steps].reverse().find((s) => s.txHash)?.txHash;
866
+ deps.state.recordProposal({
867
+ govPool,
868
+ chainId,
869
+ title: input.title,
870
+ descriptionURL,
871
+ txHash,
872
+ createdAt: new Date().toISOString(),
873
+ });
874
+ }
875
+ catch {
876
+ /* ignore */
877
+ }
878
+ }
879
+ return attachPairingQr(ok({
880
+ mode: result.mode,
881
+ proposalKind: "internal",
882
+ validators,
883
+ internalType: built.internalType,
884
+ descriptionURL,
885
+ proposalMetadataCID: cid,
886
+ summary: built.summary,
887
+ steps: result.steps,
888
+ note: "Internal proposals are created and voted on by the DAO's validators only (their own validator balances — " +
889
+ "no token deposit). The sender must be a current validator or the tx reverts.",
890
+ ...(result.enableWrites ? { enableWrites: result.enableWrites } : {}),
891
+ ...(result.pairing ? { pairing: result.pairing } : {}),
892
+ }), result.pairingContent);
893
+ }
683
894
  // ---------- register ----------
684
895
  export function registerFlowTools(server, ctx, signer, wc, state) {
685
896
  const rpc = new RpcProvider(ctx.config);
686
897
  // =============================================
687
898
  // dexe_proposal_create — thin shim around runProposalCreate
688
899
  // =============================================
689
- server.tool("dexe_proposal_create", "Create a governance proposal in ONE call — handles the whole approve→deposit→createProposalAndVote " +
690
- "sequence, uploads correct IPFS metadata (category/isMeta/changes), signs+broadcasts when " +
691
- "DEXE_PRIVATE_KEY is set (else returns ordered TxPayloads).\n\n" +
692
- "proposalType pass one of:\n" +
693
- "• 'modify_dao_profile' — change DAO profile (newDaoName/newDaoDescription/newAvatarCID/newWebsiteUrl/newSocialLinks).\n" +
694
- " 'custom' pass your own actionsOnFor [{executor,value,data}] (+ optional category).\n" +
695
- "• Wired catalog types (pass their inputs in `params`): 'token_transfer' {token,recipient,amount,isNative?}, " +
900
+ server.tool("dexe_proposal_create", "Create ANY governance proposal in ONE call — handles the whole approve→deposit→createProposalAndVote " +
901
+ "sequence, uploads correct IPFS metadata (category/isMeta/changes), signs+broadcasts when a signer is " +
902
+ "configured (else returns ordered TxPayloads + a WalletConnect QR).\n\n" +
903
+ "proposalType (every DeXe catalog type is wired):\n" +
904
+ "• 'modify_dao_profile' — top-level fields (newDaoName/newDaoDescription/newWebsiteUrl/newSocialLinks; avatar via " +
905
+ "newAvatarPatha local image path the server uploads itself — or newAvatarCID).\n" +
906
+ "• 'custom' your own actionsOnFor [{executor,value,data}] (+ optional category).\n" +
907
+ "• On-chain external types (inputs go in `params`): 'token_transfer' {token,recipient,amount,isNative?}, " +
696
908
  "'withdraw_treasury' {receiver,token?,amount?,nftAddress?,nftIds?}, 'change_voting_settings' {govSettings,settings[],settingsIds?}, " +
697
- "'add_expert' {expertNftContract,scope,nominatedUser,uri?}, 'remove_expert' {expertNftContract,scope,nominatedUser}, " +
698
- "'token_distribution' {distributionProposal,proposalId,token,amount,isNative?}, 'token_sale' {tokenSaleProposal,tiers[],latestTierId?}, " +
699
- "'custom_abi' {target,signature,method,args?,value?}.\n" +
700
- "Any other catalog type → error naming the dedicated dexe_proposal_build_* tool. See dexe_proposal_catalog.", {
909
+ "'add_expert'/'remove_expert' {expertNftContract,scope,nominatedUser,uri?}, 'token_distribution', 'token_sale', " +
910
+ "'token_sale_whitelist' {tokenSaleProposal,requests[]}, 'token_sale_recover' {tokenSaleProposal,tierIds[]}, " +
911
+ "'manage_validators'/'validators_allocation' {govValidators,changes[{user,balance}]}, " +
912
+ "'delegate_to_expert'/'revoke_from_expert' {expert,amount,nftIds?}, 'create_staking_tier', " +
913
+ "'change_math_model' {newVotePower}, 'blacklist' {erc20Gov,addAddresses?,removeAddresses?}, " +
914
+ "'reward_multiplier' {mode,...}, 'apply_to_dao' {token,receiver,amount,treasuryBalance?}, " +
915
+ "'new_proposal_type'/'enable_staking' {govSettings,settings,executors,newSettingId}, 'custom_abi' {target,signature,method,args?}.\n" +
916
+ "• Internal (validators-only, auto-routed to GovValidators.createInternalProposal): " +
917
+ "'change_validator_balances' {changes[]}, 'change_validator_settings' {duration,executionDelay,quorum}, " +
918
+ "'monthly_withdraw' {withdrawals[],destination}, 'offchain_internal_proposal' {}.\n" +
919
+ "• Off-chain backend types ('offchain_single_option' etc.) are rejected with the exact backend flow to use instead.\n" +
920
+ "Full per-type recipes with examples: docs/PLAYBOOK.md (dexe://playbook resource) or dexe_proposal_catalog.", {
701
921
  govPool: z.string().describe("GovPool contract address"),
702
922
  chainId: z
703
923
  .number()
@@ -706,13 +926,13 @@ export function registerFlowTools(server, ctx, signer, wc, state) {
706
926
  .optional()
707
927
  .describe("Target chain id. Defaults to the MCP's default chain. Rejects if no RPC is configured for the requested chain."),
708
928
  proposalType: z
709
- .string()
929
+ .enum(FLOW_PROPOSAL_TYPES)
710
930
  .default("custom")
711
- .describe("'modify_dao_profile' | 'custom' | a wired catalog type (token_transfer, withdraw_treasury, change_voting_settings, add_expert, remove_expert, token_distribution, token_sale, custom_abi)."),
931
+ .describe("One of the wired types listed in the tool description. Unknown values are rejected with the valid list."),
712
932
  params: z
713
933
  .record(z.unknown())
714
934
  .optional()
715
- .describe("Type-specific builder inputs for wired catalog proposalTypes (see tool description)."),
935
+ .describe("Type-specific builder inputs for the chosen proposalType (recipes: tool description / dexe://playbook)."),
716
936
  title: z.string().describe("Proposal title"),
717
937
  description: z.string().default("").describe("Proposal description (markdown supported)"),
718
938
  newDaoName: z.string().optional(),
@@ -720,6 +940,9 @@ export function registerFlowTools(server, ctx, signer, wc, state) {
720
940
  newWebsiteUrl: z.string().optional(),
721
941
  newAvatarCID: z.string().optional(),
722
942
  newAvatarFileName: z.string().optional(),
943
+ newAvatarPath: z.string().optional().describe("Local image path for the new avatar (JPEG/PNG/WebP/GIF, max 10 MB) — the server uploads + validates it. " +
944
+ "Preferred over reading the file yourself; replaces the separate dexe_ipfs_upload_avatar call."),
945
+ newAvatarBase64: z.string().optional().describe("Base64 image bytes — only when the image isn't a local file."),
723
946
  newSocialLinks: z.array(z.tuple([z.string(), z.string()])).optional(),
724
947
  actionsOnFor: z.array(z.object({
725
948
  executor: z.string(),
@@ -728,7 +951,10 @@ export function registerFlowTools(server, ctx, signer, wc, state) {
728
951
  })).default([]).describe("Actions for custom proposals"),
729
952
  category: z.string().optional().describe("Proposal category (included in IPFS metadata)."),
730
953
  proposalMetadataExtra: z.record(z.unknown()).optional().describe("Extra fields merged into IPFS metadata."),
731
- voteAmount: z.string().optional().describe("Auto-vote amount (18-dec wei). Defaults to all deposited power."),
954
+ voteAmount: z
955
+ .string()
956
+ .optional()
957
+ .describe("Auto-vote amount: raw wei (digits-only) OR human units with a decimal point ('12.5', scaled by the gov token's decimals). Defaults to all available power."),
732
958
  voteNftIds: z.array(z.string()).default([]),
733
959
  user: z.string().optional().describe("User address. Required when DEXE_PRIVATE_KEY not set."),
734
960
  dryRun: z.boolean().default(false).describe("If true, return ordered TxPayloads even when DEXE_PRIVATE_KEY is set."),
@@ -736,10 +962,10 @@ export function registerFlowTools(server, ctx, signer, wc, state) {
736
962
  // =============================================
737
963
  // dexe_proposal_vote_and_execute
738
964
  // =============================================
739
- server.tool("dexe_proposal_vote_and_execute", "Vote on a proposal and optionally execute it. " +
740
- "Checks proposal state, deposits tokens if needed, votes, and when autoExecute is true " +
741
- "attempts to execute after voting. When DEXE_PRIVATE_KEY is set, signs and broadcasts. " +
742
- "Otherwise returns ordered TxPayload list.", {
965
+ server.tool("dexe_proposal_vote_and_execute", "Vote on a proposal and optionally execute it — the ONE call for 'vote on / pass / execute proposal N'. " +
966
+ "Checks proposal state, AUTO-DEPOSITS wallet tokens when voting power is short (approve UserKeeper deposit → vote, " +
967
+ "matching the frontend's bundled deposit+vote), and when autoExecute is true executes after the vote passes. " +
968
+ "Signs+broadcasts when a signer is configured; otherwise returns ordered TxPayloads + a WalletConnect QR.", {
743
969
  govPool: z.string().describe("GovPool contract address"),
744
970
  chainId: z
745
971
  .number()
@@ -749,9 +975,17 @@ export function registerFlowTools(server, ctx, signer, wc, state) {
749
975
  .describe("Target chain id. Defaults to the MCP's default chain. Rejects if no RPC is configured for the requested chain."),
750
976
  proposalId: z.number().int().min(1).describe("Proposal ID (1-indexed)"),
751
977
  isVoteFor: z.boolean().default(true).describe("Vote for (true) or against (false)"),
752
- voteAmount: z.string().optional().describe("Vote amount (18-dec wei). Defaults to all deposited power."),
978
+ voteAmount: z
979
+ .string()
980
+ .optional()
981
+ .describe("Vote amount: raw wei (digits-only string) OR human units with a decimal point ('12.5', scaled by the gov " +
982
+ "token's decimals). Defaults to ALL available power (deposited + wallet)."),
753
983
  voteNftIds: z.array(z.string()).default([]),
754
- depositFirst: z.boolean().default(false).describe("Deposit wallet tokens before voting"),
984
+ depositFirst: z
985
+ .union([z.boolean(), z.literal("auto")])
986
+ .default("auto")
987
+ .describe("'auto' (default): deposit exactly the missing amount from the wallet when deposited power is short of " +
988
+ "voteAmount. true: deposit the full wallet balance. false: never deposit (vote with already-deposited power only)."),
755
989
  autoExecute: z.boolean().default(true).describe("Attempt execute if proposal passes after vote"),
756
990
  dryRun: z.boolean().default(false).describe("If true, return ordered TxPayloads even when DEXE_PRIVATE_KEY is set (preview without broadcasting)."),
757
991
  user: z.string().optional().describe("User address. Required when DEXE_PRIVATE_KEY not set."),
@@ -794,6 +1028,9 @@ export function registerFlowTools(server, ctx, signer, wc, state) {
794
1028
  const execResult = await sendOrCollect(signer, [
795
1029
  makeTxPayload(govPool, GOV_POOL_ABI, "execute", [proposalId], chainId, `GovPool.execute(${proposalId})`),
796
1030
  ], { dryRun: input.dryRun, chainId, wc });
1031
+ if (execResult.mode === "failed") {
1032
+ return flowFailureResult(execResult, { proposalId, proposalStateBefore: stateName });
1033
+ }
797
1034
  return attachPairingQr(ok({
798
1035
  mode: execResult.mode,
799
1036
  proposalId,
@@ -809,43 +1046,86 @@ export function registerFlowTools(server, ctx, signer, wc, state) {
809
1046
  }), execResult.pairingContent);
810
1047
  }
811
1048
  if (stateNum !== 0) {
812
- return err(`Proposal #${proposalId} is in state "${stateName}" — voting requires "Voting" state.`);
813
- }
814
- // Step 2: resolve prereqs for deposit check
815
- let prereqs;
816
- if (input.depositFirst) {
817
- prereqs = await resolvePrereqs(rpc, govPool, user, ctx.config, chainId);
1049
+ const remedies = {
1050
+ 1: "It is waiting for the validator-voting transfer — check again shortly with dexe_proposal_state.",
1051
+ 2: "It is in validator voting — only the DAO's validators can act now; wait for their vote.",
1052
+ 3: "It was DEFEATED — voting is over. Create a new proposal if the change is still wanted.",
1053
+ 4: "It already PASSED — re-run this call with autoExecute:true (the default) to execute it, no vote needed.",
1054
+ 5: "It already passed AGAINST re-run this call with autoExecute:true to execute the against-actions.",
1055
+ 6: "It is Locked (passed, execution delay running) — re-run this call with autoExecute:true once the delay elapses.",
1056
+ 7: "It was already EXECUTED (for) — nothing left to do.",
1057
+ 8: "It was already EXECUTED (against) — nothing left to do.",
1058
+ };
1059
+ return err(`Proposal #${proposalId} is in state "${stateName}" — voting is only possible in "Voting". ` +
1060
+ (remedies[stateNum] ?? "Check dexe_proposal_state for details."));
818
1061
  }
1062
+ // Step 2: resolve prereqs — always needed now (auto-deposit detection +
1063
+ // minVotes threshold + human-unit rendering).
1064
+ const prereqs = await resolvePrereqs(rpc, govPool, user, ctx.config, chainId);
1065
+ const d = prereqs.tokenDecimals;
1066
+ const sym = prereqs.tokenSymbol;
819
1067
  const payloads = [];
820
1068
  const skippedSteps = [];
821
- // Step 3: optional deposit
822
- if (input.depositFirst && prereqs && prereqs.walletBalance > 0n) {
823
- // Approve if needed
824
- if (prereqs.currentAllowance < prereqs.walletBalance) {
825
- // W10: exact-amount approve, never MAX_UINT256.
826
- payloads.push(makeTxPayload(prereqs.tokenAddress, ERC20_ABI, "approve", [prereqs.userKeeper, prereqs.walletBalance], chainId, `ERC20.approve(${prereqs.userKeeper}, ${prereqs.walletBalance})`));
827
- }
828
- // Deposit
829
- payloads.push(makeTxPayload(govPool, GOV_POOL_ABI, "deposit", [prereqs.walletBalance, []], chainId, `GovPool.deposit(${prereqs.walletBalance})`));
1069
+ // Step 3: target vote amount (raw wei digits-only, or human decimal).
1070
+ let voteAmt;
1071
+ try {
1072
+ voteAmt = input.voteAmount
1073
+ ? parseAmount(input.voteAmount, d)
1074
+ : input.depositFirst === false
1075
+ ? prereqs.depositedPower
1076
+ : prereqs.depositedPower + prereqs.walletBalance;
1077
+ }
1078
+ catch (e) {
1079
+ return err(e instanceof Error ? e.message : String(e));
830
1080
  }
831
- // Step 4: vote
832
- const voteAmt = input.voteAmount
833
- ? BigInt(input.voteAmount)
834
- : (prereqs ? prereqs.depositedPower + prereqs.walletBalance : 0n);
835
1081
  if (voteAmt === 0n) {
836
- return err("No voting power available. Deposit tokens before voting.");
1082
+ return err(input.depositFirst === false
1083
+ ? `No deposited voting power (wallet holds ${formatAmount(prereqs.walletBalance, d, sym)}, deposited 0). ` +
1084
+ `Re-run with depositFirst:'auto' (the default) to deposit-and-vote in one call.`
1085
+ : `No voting power available — ${user} holds 0 ${sym || "gov tokens"} (wallet + deposited). ` +
1086
+ `Acquire the DAO's gov token (${prereqs.tokenAddress}) first.`);
837
1087
  }
838
- // Check minVotesForVoting threshold
839
- if (!prereqs && !input.voteAmount) {
840
- // Need prereqs to validate threshold fetch them
841
- prereqs = await resolvePrereqs(rpc, govPool, user, ctx.config, chainId);
1088
+ // Step 4: deposit decision.
1089
+ // 'auto' → deposit exactly the shortfall (frontend-equivalent bundled deposit+vote)
1090
+ // true → legacy explicit: deposit the full wallet balance
1091
+ // false → never deposit
1092
+ let depositAmount = 0n;
1093
+ if (input.depositFirst === true) {
1094
+ depositAmount = prereqs.walletBalance;
842
1095
  }
843
- if (prereqs && prereqs.minVotesForVoting > 0n && voteAmt < prereqs.minVotesForVoting) {
844
- return err(`Insufficient voting power. Need ${prereqs.minVotesForVoting} but voting with ${voteAmt}.`);
1096
+ else if (input.depositFirst !== false && voteAmt > prereqs.depositedPower) {
1097
+ const shortfall = voteAmt - prereqs.depositedPower;
1098
+ if (shortfall > prereqs.walletBalance) {
1099
+ return err(`Not enough tokens: voting with ${formatAmount(voteAmt, d, sym)} needs ${formatAmount(shortfall, d, sym)} more deposited, ` +
1100
+ `but the wallet only holds ${formatAmount(prereqs.walletBalance, d, sym)} ` +
1101
+ `(deposited ${formatAmount(prereqs.depositedPower, d, sym)}). ` +
1102
+ `Lower voteAmount to at most ${formatAmount(prereqs.depositedPower + prereqs.walletBalance, d, sym)}, or acquire more tokens.`);
1103
+ }
1104
+ depositAmount = shortfall;
1105
+ }
1106
+ if (depositAmount > 0n) {
1107
+ // Approve if needed — W10: exact-amount approve to the UserKeeper
1108
+ // (never GovPool, never MAX_UINT256).
1109
+ if (prereqs.currentAllowance < depositAmount) {
1110
+ payloads.push(makeTxPayload(prereqs.tokenAddress, ERC20_ABI, "approve", [prereqs.userKeeper, depositAmount], chainId, `ERC20.approve(${prereqs.userKeeper}, ${depositAmount})`));
1111
+ }
1112
+ payloads.push(makeTxPayload(govPool, GOV_POOL_ABI, "deposit", [depositAmount, []], chainId, `GovPool.deposit(${depositAmount})`));
1113
+ }
1114
+ else if (input.depositFirst !== false) {
1115
+ skippedSteps.push({ label: "GovPool.deposit", skipped: true, reason: "Deposited power already covers voteAmount" });
1116
+ }
1117
+ // Step 5: minVotesForVoting threshold
1118
+ if (prereqs.minVotesForVoting > 0n && voteAmt < prereqs.minVotesForVoting) {
1119
+ return err(`Vote below this DAO's minimum: voting requires at least ${formatAmount(prereqs.minVotesForVoting, d, sym)} ` +
1120
+ `but this vote would cast ${formatAmount(voteAmt, d, sym)}. ` +
1121
+ `Raise voteAmount (you have ${formatAmount(prereqs.depositedPower + prereqs.walletBalance, d, sym)} total) or acquire more tokens.`);
845
1122
  }
846
1123
  payloads.push(makeTxPayload(govPool, GOV_POOL_ABI, "vote", [proposalId, input.isVoteFor, voteAmt, input.voteNftIds.map(id => BigInt(id))], chainId, `GovPool.vote(${proposalId}, ${input.isVoteFor}, ${voteAmt})`));
847
1124
  // Step 5: send or collect
848
1125
  const result = await sendOrCollect(signer, payloads, { dryRun: input.dryRun, chainId, wc });
1126
+ if (result.mode === "failed") {
1127
+ return flowFailureResult(result, { proposalId, proposalStateBefore: stateName });
1128
+ }
849
1129
  // Step 6: auto-execute (only in executed mode)
850
1130
  let executed = false;
851
1131
  if (input.autoExecute && result.mode === "executed") {
@@ -870,6 +1150,11 @@ export function registerFlowTools(server, ctx, signer, wc, state) {
870
1150
  makeTxPayload(govPool, GOV_POOL_ABI, "execute", [proposalId], chainId, `GovPool.execute(${proposalId})`),
871
1151
  ], { dryRun: input.dryRun, chainId, wc });
872
1152
  result.steps.push(...execResult.steps);
1153
+ if (execResult.mode === "failed") {
1154
+ // The vote landed; only the execute failed. Surface the ledger —
1155
+ // the proposal stays executable via a re-run or dexe_vote_build_execute.
1156
+ return flowFailureResult({ steps: result.steps, failure: execResult.failure }, { proposalId, proposalStateBefore: stateName, voteLanded: true });
1157
+ }
873
1158
  executed = true;
874
1159
  }
875
1160
  else {